mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
d789a7c528
commit
5b84691770
2 changed files with 184 additions and 40 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue