fix(wrap): write env.ANTHROPIC_BASE_URL to settings.json so daemon-spawned conversations inherit proxy (#951) (#1078)

## Description

Claude Code pre-forks conversation workers via spawn (not fork) on
macOS. Those workers read settings files fresh on each new session
rather than inheriting the daemon process's environment. `headroom wrap
claude` was passing `ANTHROPIC_BASE_URL` only via `subprocess.run`'s
`env` dict, which reaches the initial Claude Code process and the daemon
— but not conversation workers spawned later from the daemon pool. New
conversations silently bypassed the proxy and hit `api.anthropic.com`
directly.

### Design decision: why project-local settings

Three approaches were considered:

**1. Global `~/.claude/settings.json`** — rejected. This file is shared
across every Claude Code session on the machine. A user who runs
`headroom wrap claude` in one terminal but opens an unwrapped session
elsewhere would have their global settings rewritten to point at the
Headroom proxy. If the proxy dies and cleanup does not run (SIGKILL,
crash), the stale URL breaks all future sessions until the user manually
edits the global file.

**2. Kill cc-daemon before launch** — rejected. The issue itself
suggests this, but killing the daemon is disruptive: it destroys the
pre-forked worker pool shared by any other open Claude Code windows.
Active conversations may lose their parent process. This is a
hard-to-reverse side-effect of a command the user expects to be safe.

**3. Project-local `<cwd>/.claude/settings.local.json`** — chosen.
Claude Code applies `env` keys from project-local settings per its
documented precedence order (Local > Project > User), and reloads them
per-conversation. Scoping to the project means: other projects and
unwrapped sessions are unaffected; the file is git-ignored by default so
it won't be committed; and the worst-case stale URL (proxy crash without
cleanup) affects only that one project's local settings and is trivially
recoverable by re-running `headroom wrap claude` or deleting the file.

Closes #951

## Type of Change

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

## Changes Made

- Added `_write_claude_wrap_base_url(proxy_url, *, foundry_mode,
settings_path)` in `headroom/cli/wrap.py`: merges `ANTHROPIC_BASE_URL`
(or `ANTHROPIC_FOUNDRY_BASE_URL` in foundry mode) into
`<cwd>/.claude/settings.local.json` under the `env` key. Returns the
previous value for restore.
- Added `_restore_claude_wrap_base_url(previous, *, foundry_mode,
settings_path)`: called in the `wrap claude` `finally` block and in
`unwrap_claude` to remove or restore the key so a stale proxy URL is
never left behind.
- `unwrap_claude` calls restore for both standard and foundry keys.
- New test file `tests/test_cli/test_wrap_claude_base_url.py` (12 tests
covering write, restore, roundtrip, foundry mode, sibling key
preservation, and noop on absent file).

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
pytest tests/test_cli/test_wrap_claude_base_url.py -v
12 passed in 0.21s

ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_claude_base_url.py
All checks passed!
```

## Real Behavior Proof

- Environment: macOS, branch `fix/daemon-base-url-inheritance`, Python
3.11.9.
- Exact command / steps: Ran `pytest
tests/test_cli/test_wrap_claude_base_url.py -v` and `ruff check` on the
modified files from the PR branch.
- Observed result: 12 new unit tests pass; ruff reports no issues.
- Not tested: Live end-to-end verification (opening a second
conversation via the daemon pool and confirming proxy receives traffic)
— not safe to test inside the current wrapped session on port 8787.

## Review Readiness

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

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

The issue reporter tried `apiBaseUrl` in settings.json and found it
ineffective. That key configures the API endpoint at the CC UI layer,
not the process environment. `env.ANTHROPIC_BASE_URL` is the correct
mechanism for propagating an environment variable to CC worker
processes.
This commit is contained in:
Focused Instability 2026-06-18 18:21:04 +02:00 committed by GitHub
parent 9f712ccbd7
commit a554c3a0e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 285 additions and 0 deletions

View file

@ -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:

View file

@ -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"