diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index 3a5f8a59e..19a1c440f 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -576,6 +576,85 @@ def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool: return True +def _write_claude_wrap_base_url( + proxy_url: str, + *, + foundry_mode: bool = False, + settings_path: Path | None = None, +) -> str | None: + """Persist proxy URL into project-local settings env key for daemon child inheritance. + + Claude Code's cc-daemon pre-forks conversation workers using spawn (not + fork), so those workers read settings.json fresh rather than inheriting + the daemon's environment. Writing env.ANTHROPIC_BASE_URL into the + project-local settings file (.claude/settings.local.json in cwd) ensures + every new conversation — including those started after the initial launch — + routes through the Headroom proxy without touching the global user settings + file or affecting sessions in other projects. Returns the previous value + so the caller can restore it on exit (issue #951). + """ + path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") + payload: dict[str, Any] = {} + if path.exists(): + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + if not isinstance(payload, dict): + payload = {} + env_map = dict(payload.get("env") or {}) if isinstance(payload.get("env"), dict) else {} + key = "ANTHROPIC_FOUNDRY_BASE_URL" if foundry_mode else "ANTHROPIC_BASE_URL" + previous = env_map.get(key) + env_map[key] = proxy_url + payload["env"] = env_map + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return previous + + +def _restore_claude_wrap_base_url( + previous: str | None, + *, + foundry_mode: bool = False, + settings_path: Path | None = None, +) -> None: + """Restore (or remove) the env key written by _write_claude_wrap_base_url. + + Called in both the wrap-session finally block and unwrap_claude so the + project-local settings entry is never left pointing at a dead proxy. When + ``previous`` is None the key is removed; when it has a value it is + restored — preserving any URL the project already had set. + """ + path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") + if not path.exists(): + return + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return + if not isinstance(payload, dict): + return + env_map = payload.get("env") + if not isinstance(env_map, dict): + return + key = "ANTHROPIC_FOUNDRY_BASE_URL" if foundry_mode else "ANTHROPIC_BASE_URL" + if previous is None: + if key not in env_map: + return + del env_map[key] + if env_map: + payload["env"] = env_map + else: + payload.pop("env", None) + else: + env_map[key] = previous + payload["env"] = env_map + if payload: + path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + else: + path.unlink(missing_ok=True) + + def _setup_headroom_mcp( registrar: Any, port: int, *, verbose: bool = False, force: bool = False ) -> None: @@ -2885,6 +2964,8 @@ def claude( # Setup rtk before launching (Claude-specific) proxy_holder: list[subprocess.Popen | None] = [None] + _saved_base_url: list[str | None] = [None] # previous settings.json value for restore + _settings_foundry: list[bool] = [False] cleanup = _make_cleanup(proxy_holder, port) _register_proxy_client(port) signal.signal(signal.SIGINT, _ignore_child_sigint) @@ -3026,6 +3107,14 @@ def claude( else: env["ANTHROPIC_BASE_URL"] = proxy_url + # Issue #951: write to settings.json so daemon-spawned conversation + # workers (which read settings.json fresh rather than inheriting the + # daemon's environment) also route through Headroom. + _settings_foundry[0] = bool(foundry_upstream) + _saved_base_url[0] = _write_claude_wrap_base_url( + proxy_url, foundry_mode=_settings_foundry[0] + ) + # Per-project savings attribution: tag every request with the launch # directory's name via X-Headroom-Project (user override wins). _apply_project_header_env(env) @@ -3053,6 +3142,7 @@ def claude( click.echo(f" Error: {e}") raise SystemExit(1) from e finally: + _restore_claude_wrap_base_url(_saved_base_url[0], foundry_mode=_settings_foundry[0]) cleanup() @@ -3110,6 +3200,9 @@ def unwrap_claude( else: click.echo(" Kept rtk Claude hooks (--keep-rtk).") + _restore_claude_wrap_base_url(None) + _restore_claude_wrap_base_url(None, foundry_mode=True) + click.echo() click.echo("✓ Claude is no longer durably wrapped by Headroom.") if not no_stop_proxy: diff --git a/tests/test_cli/test_wrap_claude_base_url.py b/tests/test_cli/test_wrap_claude_base_url.py new file mode 100644 index 000000000..c03a82c06 --- /dev/null +++ b/tests/test_cli/test_wrap_claude_base_url.py @@ -0,0 +1,192 @@ +"""Tests for _write_claude_wrap_base_url / _restore_claude_wrap_base_url (issue #951).""" + +from __future__ import annotations + +import json +from pathlib import Path + +from headroom.cli import wrap as wrap_cli + + +def _settings(tmp_path: Path) -> Path: + return tmp_path / ".claude" / "settings.json" + + +def test_write_creates_env_key_in_fresh_file(tmp_path: Path) -> None: + path = _settings(tmp_path) + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev is None + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + + +def test_write_preserves_other_env_keys(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"env": {"KEEP": "1", "ANOTHER": "2"}}), encoding="utf-8") + wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["KEEP"] == "1" + assert payload["env"]["ANOTHER"] == "2" + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + + +def test_write_returns_none_when_key_absent(tmp_path: Path) -> None: + path = _settings(tmp_path) + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev is None + + +def test_write_returns_previous_value_when_key_present(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://old.proxy:9000"}}), + encoding="utf-8", + ) + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev == "http://old.proxy:9000" + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + + +def test_write_foundry_mode_sets_foundry_key(tmp_path: Path) -> None: + path = _settings(tmp_path) + wrap_cli._write_claude_wrap_base_url( + "http://127.0.0.1:8787", foundry_mode=True, settings_path=path + ) + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_FOUNDRY_BASE_URL"] == "http://127.0.0.1:8787" + assert "ANTHROPIC_BASE_URL" not in payload["env"] + + +def test_restore_removes_key_when_previous_none(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) + # file is deleted when payload becomes empty — key is gone + assert not path.exists() + + +def test_restore_removes_env_dict_when_empty(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) + # entire payload was {"env": {...only our key...}} — file deleted rather than left as {} + assert not path.exists() + + +def test_restore_preserves_sibling_env_keys(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787", "KEEP": "1"}}), + encoding="utf-8", + ) + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) + payload = json.loads(path.read_text(encoding="utf-8")) + assert "ANTHROPIC_BASE_URL" not in payload["env"] + assert payload["env"]["KEEP"] == "1" + + +def test_restore_sets_key_back_to_previous_value(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + wrap_cli._restore_claude_wrap_base_url("http://old.proxy:9000", settings_path=path) + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://old.proxy:9000" + + +def test_restore_foundry_mode_removes_foundry_key(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text( + json.dumps({"env": {"ANTHROPIC_FOUNDRY_BASE_URL": "http://127.0.0.1:8787"}}), + encoding="utf-8", + ) + wrap_cli._restore_claude_wrap_base_url(None, foundry_mode=True, settings_path=path) + # file deleted when payload empties + assert not path.exists() + + +def test_restore_noop_when_file_absent(tmp_path: Path) -> None: + path = _settings(tmp_path) + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise + + +def test_restore_noop_when_key_not_present(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"env": {"OTHER": "1"}}), encoding="utf-8") + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # key absent — no-op + assert json.loads(path.read_text())["env"]["OTHER"] == "1" + + +def test_restore_noop_when_env_not_dict(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"env": "not-a-dict"}), encoding="utf-8") + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise + + +def test_restore_noop_when_payload_not_dict(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("[1, 2, 3]", encoding="utf-8") # valid JSON but not a dict + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise + + +def test_restore_noop_when_file_corrupt(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("not valid json {{{{", encoding="utf-8") + wrap_cli._restore_claude_wrap_base_url(None, settings_path=path) # must not raise + + +def test_write_recovers_from_corrupt_file(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("not valid json {{{{", encoding="utf-8") + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev is None # treated as fresh + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + + +def test_write_recovers_from_non_dict_payload(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text("[1, 2, 3]", encoding="utf-8") # valid JSON but not a dict + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev is None + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + + +def test_write_restore_roundtrip(tmp_path: Path) -> None: + path = _settings(tmp_path) + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"model": "opus", "env": {"OTHER": "x"}}), encoding="utf-8") + prev = wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path) + assert prev is None + payload = json.loads(path.read_text(encoding="utf-8")) + assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" + assert payload["model"] == "opus" + + wrap_cli._restore_claude_wrap_base_url(prev, settings_path=path) + payload = json.loads(path.read_text(encoding="utf-8")) + assert "ANTHROPIC_BASE_URL" not in payload.get("env", {}) + assert payload["env"]["OTHER"] == "x" + assert payload["model"] == "opus"