fix(unwrap): remove ANTHROPIC_BASE_URL + ENABLE_TOOL_SEARCH and init hooks on unwrap (#992)

## Description

`headroom init claude` writes `env.ANTHROPIC_BASE_URL` (and
`ENABLE_TOOL_SEARCH`) plus SessionStart/PreToolUse hooks (marker
`headroom-init-claude`) into settings.json. But `unwrap` only matched
`rtk-rewrite` hooks and never removed the env, and it returned early
when no hooks remained — so the routing env survived unwrap, leaving
`claude` pointed at a dead proxy.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)

## Changes Made

- Broaden the hook-marker match to include `headroom-init-claude`.
- Always strip the headroom-managed env vars (`ANTHROPIC_BASE_URL`,
`ENABLE_TOOL_SEARCH`) even when no hooks remain.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ pytest tests/test_cli/test_unwrap_claude.py -q
9 passed in 0.97s
```

## Real Behavior Proof

- Environment: Linux, Python 3.13, isolated $HOME
- Exact command / steps: `headroom init -g claude` then `headroom unwrap
claude`
- Observed result: after unwrap, settings.json `env` is empty/removed
and `hooks` is `[]` (both env vars and the init hooks gone)
- Not tested: Windows settings path

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Eyal Mizrachi 2026-06-19 12:30:59 -04:00 committed by GitHub
parent d789a7c528
commit 5b84691770
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 184 additions and 40 deletions

View file

@ -515,12 +515,26 @@ def _setup_lean_ctx_agent(agent: str, verbose: bool = False) -> Path | None:
return lean_ctx
def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool:
"""Remove Headroom/rtk-managed Claude hook entries from settings.json.
# Hook-command markers Headroom manages in Claude settings.json. unwrap drops
# any hook entry whose command contains one of these.
_HEADROOM_HOOK_MARKERS = ("rtk-rewrite", "headroom-init-claude")
`rtk init --global --auto-patch` installs a Claude PreToolUse hook that
points at an ``rtk-rewrite`` script. Unwrap should remove that hook without
touching unrelated Claude settings or user-authored hooks.
# Env vars Headroom's init/wrap inject into Claude settings.json; unwrap removes
# them. ENABLE_TOOL_SEARCH keeps Claude Code's tool deferral on behind the proxy
# (GH #746), paired with init/wrap setting it.
_HEADROOM_ENV_KEYS = ("ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH")
def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool:
"""Remove Headroom-managed entries from Claude settings.json.
Reverses what ``headroom init claude`` and ``rtk init --auto-patch`` add:
* PreToolUse / SessionStart hooks whose command contains a Headroom marker
(``rtk-rewrite`` or ``headroom-init-claude``), and
* the ``ANTHROPIC_BASE_URL`` proxy-routing env var.
Unrelated settings and user-authored hooks are left untouched. (Previously
this only matched ``rtk-rewrite`` and returned early when no hooks existed,
so init's env + hooks survived unwrap.)
"""
path = settings_path or (Path.home() / ".claude" / "settings.json")
@ -534,51 +548,68 @@ def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool:
if not isinstance(payload, dict):
return False
hooks = payload.get("hooks")
if not isinstance(hooks, dict):
return False
changed = False
for event, entries in list(hooks.items()):
if not isinstance(entries, list):
continue
retained_entries: list[Any] = []
for entry in entries:
if not isinstance(entry, dict):
retained_entries.append(entry)
hooks = payload.get("hooks")
if isinstance(hooks, dict):
for event, entries in list(hooks.items()):
if not isinstance(entries, list):
continue
hook_items = entry.get("hooks")
if not isinstance(hook_items, list):
retained_entries.append(entry)
continue
retained_hooks = [
item
for item in hook_items
if not (
isinstance(item, dict) and "rtk-rewrite" in str(item.get("command", "")).lower()
)
]
if len(retained_hooks) != len(hook_items):
changed = True
if retained_hooks:
retained_entries.append({**entry, "hooks": retained_hooks})
elif len(retained_hooks) == len(hook_items):
retained_entries.append(entry)
retained_entries: list[Any] = []
for entry in entries:
if not isinstance(entry, dict):
retained_entries.append(entry)
continue
hook_items = entry.get("hooks")
if not isinstance(hook_items, list):
retained_entries.append(entry)
continue
retained_hooks = [
item
for item in hook_items
if not (
isinstance(item, dict)
and any(
marker in str(item.get("command", "")).lower()
for marker in _HEADROOM_HOOK_MARKERS
)
)
]
if len(retained_hooks) != len(hook_items):
changed = True
if retained_hooks:
retained_entries.append({**entry, "hooks": retained_hooks})
elif len(retained_hooks) == len(hook_items):
retained_entries.append(entry)
else:
changed = True
if retained_entries:
hooks[event] = retained_entries
else:
del hooks[event]
changed = True
if retained_entries:
hooks[event] = retained_entries
if hooks:
payload["hooks"] = hooks
else:
del hooks[event]
payload.pop("hooks", None)
# Remove the proxy-routing env that init/wrap injected (ANTHROPIC_BASE_URL and
# ENABLE_TOOL_SEARCH), even when no hooks remain (the early-return bug skipped
# this). List-comp, not any(), so every key is popped (no short-circuit).
env = payload.get("env")
if isinstance(env, dict):
removed_keys = [k for k in _HEADROOM_ENV_KEYS if env.pop(k, None) is not None]
if removed_keys:
changed = True
if env:
payload["env"] = env
else:
payload.pop("env", None)
if not changed:
return False
if hooks:
payload["hooks"] = hooks
else:
payload.pop("hooks", None)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return True

View file

@ -208,3 +208,116 @@ def test_unwrap_claude_keep_flags_skip_cleanup(
registrar.assert_not_called()
remove_rtk.assert_not_called()
stop_proxy.assert_not_called()
def test_remove_claude_rtk_hooks_removes_init_hooks_and_env(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps(
{
"model": "opus",
"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787", "FOO": "bar"},
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": (
"/home/u/.local/bin/headroom init hook ensure "
"--profile init-user --marker headroom-init-claude"
),
"timeout": 15,
}
],
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "headroom init hook ensure --marker headroom-init-claude",
},
{"type": "command", "command": "echo keep-me"},
],
}
],
},
}
)
+ "\n",
encoding="utf-8",
)
assert wrap_cli._remove_claude_rtk_hooks(settings) is True
payload = json.loads(settings.read_text(encoding="utf-8"))
# ANTHROPIC_BASE_URL stripped; unrelated env var preserved
assert payload.get("env") == {"FOO": "bar"}
# SessionStart removed entirely (its only hook was the init marker)
assert "SessionStart" not in payload.get("hooks", {})
# PreToolUse: init-marker hook gone, unrelated hook kept
assert payload["hooks"]["PreToolUse"][0]["hooks"] == [
{"type": "command", "command": "echo keep-me"}
]
assert payload["model"] == "opus"
def test_remove_claude_rtk_hooks_strips_env_without_hooks(tmp_path: Path) -> None:
# Regression: unwrap previously returned early when no hooks existed,
# leaving init's ANTHROPIC_BASE_URL behind in settings.json.
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}) + "\n",
encoding="utf-8",
)
assert wrap_cli._remove_claude_rtk_hooks(settings) is True
payload = json.loads(settings.read_text(encoding="utf-8"))
assert "env" not in payload # emptied env dict is dropped
def test_remove_claude_rtk_hooks_noop_when_nothing_managed(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
original = {
"model": "opus",
"env": {"FOO": "bar"},
"hooks": {
"PreToolUse": [
{"matcher": "Bash", "hooks": [{"type": "command", "command": "echo hi"}]}
]
},
}
settings.write_text(json.dumps(original) + "\n", encoding="utf-8")
assert wrap_cli._remove_claude_rtk_hooks(settings) is False
# nothing managed -> file untouched
assert json.loads(settings.read_text(encoding="utf-8")) == original
def test_remove_claude_rtk_hooks_strips_enable_tool_search(tmp_path: Path) -> None:
# unwrap must remove BOTH env vars init writes (ANTHROPIC_BASE_URL +
# ENABLE_TOOL_SEARCH, GH #746), leaving user-set vars intact.
settings = tmp_path / "settings.json"
settings.write_text(
json.dumps(
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787",
"ENABLE_TOOL_SEARCH": "true",
"KEEP": "1",
}
}
)
+ "\n",
encoding="utf-8",
)
assert wrap_cli._remove_claude_rtk_hooks(settings) is True
payload = json.loads(settings.read_text(encoding="utf-8"))
assert payload["env"] == {"KEEP": "1"}