fix: detect and clear stale ANTHROPIC_BASE_URL from crashed wrap sessions (#1768) (#1837)

## Description

`headroom wrap claude` writes `env.ANTHROPIC_BASE_URL` (or the
foundry/vertex variant) into a project's `.claude/settings.local.json`
so daemon-spawned Claude Code workers route through the local Headroom
proxy. Removal only happened in the wrap process's `finally:` block. An
unclean exit — `SIGKILL`, OOM, reboot, or terminal/tmux close (`SIGHUP`,
which was not caught; only `SIGINT`/`SIGTERM` were) — skipped that
cleanup, so the entry persisted indefinitely. Every subsequent bare
`claude` in that project then routed to the dead port and hung
indefinitely retrying it.

Closes #1768

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `_write_claude_wrap_base_url` now optionally stamps a sidecar marker
(`.claude/.headroom_wrap_marker.json`) recording the writer's
pid/identity, the port, and the true prior value — kept out of
`settings.local.json` itself so Headroom bookkeeping never shows up as a
stray key in a file Claude Code's own config loader parses.
- A shared `_identity_mismatch` helper (factored out of the existing
`_marker_pid_reused` proxy-client-refcounting logic) lets a marker be
judged stale: missing/invalid pid, dead pid, or a live pid whose
identity doesn't match the recorded one (PID reuse after a crash).
- `claude()` now checks for — and self-heals — a stale marker
immediately before writing a fresh entry, restoring the recorded prior
value instead of trusting a leftover from a dead session.
- `claude()` now also registers a `SIGHUP` handler (guarded via
`hasattr`, since Windows has none) alongside the existing `SIGTERM`
handler, so terminal-close triggers the same cleanup/restore path.
- `headroom unwrap claude` now reads the marker's recorded prior value
before restoring, instead of unconditionally deleting the key — so a
user's own pre-existing `ANTHROPIC_BASE_URL` (set before ever running
`wrap`) isn't blindly wiped.
- `headroom doctor` gained a new check (`check_wrap_marker_staleness`)
that flags a stale project-local marker and points at `headroom unwrap
claude` to clean it up — separate from the existing global-settings
`check_claude_routing` check.
- (Unrelated, pre-existing on `main`) reformatted
`headroom/proxy/handlers/openai.py`,
`tests/test_openai_codex_ws_lifecycle.py`, `tests/test_output_shaper.py`
— whitespace/indentation only, no logic change — since they were already
failing `ruff format --check .` on `main` before this branch touched
anything, and the repo-wide lint gate blocks on it.

Out of scope: `wrap --worktree` — no such flag or multi-worktree
`.claude` handling exists anywhere in `wrap.py` today; not adding new
surface for an aspirational scenario the issue mentions but that isn't
implemented.

## Testing

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

### Test Output

```text
$ pytest tests/test_cli/test_wrap_claude_base_url.py tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_stale_marker.py -q
42 passed

$ pytest tests/test_cli -q
512 passed, 1 failed (test_wrap_codex_prepare_only_registers_serena_when_uvx_exists —
confirmed to fail identically on a clean checkout of main with no changes applied;
test-order flake, unrelated to this PR)

$ ruff check .
All checks passed!

$ ruff format --check .
1047 files already formatted

$ mypy headroom/cli/wrap.py headroom/cli/doctor.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: local checkout, Python 3.13, Windows.
- Exact command / steps: wrote a base_url entry + marker via
`_write_claude_wrap_base_url(..., port=8787)`, then overwrote the
marker's recorded pid with a value guaranteed not to be a live process
(simulating the crash from the issue's own repro: `headroom wrap claude
-- -p ok & ; kill -9 <wrap-pid>`). Ran
`headroom.cli.doctor.check_wrap_marker_staleness()` against that path,
then called `_check_and_clear_stale_wrap_marker()` (the same check
`claude()` now runs before writing a fresh entry).
- Observed result: `doctor`'s check correctly reports `WARN` naming the
dead pid/port and pointing at `headroom unwrap claude`. The stale-check
call then self-heals: in the "nothing existed before wrap" case the
leaked entry is removed; in a second run seeded with a real pre-existing
`ANTHROPIC_BASE_URL` (set before `wrap` ever ran), that original value
is recovered instead of being deleted. In both cases the marker file is
cleared afterward.
- Not tested: actual OS-level signal delivery (`kill -HUP` against a
real running `headroom wrap claude` subprocess) — the SIGHUP
registration is exercised via a source-inspection test instead of a live
signal, since spawning/killing the real CLI subprocess isn't practical
in this environment; verified E2E via CI's `wrap-native` jobs
(Ubuntu/macOS) which passed.

## 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
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — CLI/backend fix, no UI surface.

## Additional Notes

- Documentation checklist item left unchecked: no user-facing docs
currently describe wrap's settings.local.json write/cleanup behavior in
enough detail to need updating; happy to add a troubleshooting note if
maintainers want one.
- `wrap --worktree` handling is out of scope (see Changes Made) —
flagging in case maintainers want it tracked as a separate follow-up
issue.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-07-06 17:35:40 +02:00 committed by GitHub
parent 5e29c06aaf
commit 84509a4b89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 347 additions and 20 deletions

View file

@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Fixed ### Fixed
- `headroom wrap claude` no longer leaves a dead `ANTHROPIC_BASE_URL` in a
project's `.claude/settings.local.json` after an unclean exit (`SIGKILL`,
OOM, reboot, or terminal/tmux close via `SIGHUP`, which was not caught).
`_write_claude_wrap_base_url`/`_restore_claude_wrap_base_url` only removed
or restored the entry from the wrap process's own `finally` block, so a
crash skipped it and every later bare `claude` invocation in that project
inherited the stale proxy URL and hung indefinitely retrying a dead port.
A wrap session now stamps a sidecar marker (pid, port, prior value); the
next `wrap`, `unwrap`, or `headroom doctor` run detects a marker whose pid
is dead or reused and restores the recorded prior value automatically.
`claude()` also now catches `SIGHUP` alongside the existing `SIGTERM`
handler ([#1768](https://github.com/headroomlabs-ai/headroom/issues/1768)).
- Non-finite values (`NaN`, `Infinity`) in `proxy_savings.json` or in upstream - Non-finite values (`NaN`, `Infinity`) in `proxy_savings.json` or in upstream
cost/token metadata no longer crash the proxy or corrupt the savings cost/token metadata no longer crash the proxy or corrupt the savings
dashboard. `SavingsTracker`'s numeric coercion caught only `TypeError` and dashboard. `SavingsTracker`'s numeric coercion caught only `TypeError` and

View file

@ -33,6 +33,7 @@ from headroom.providers.claude import (
) )
from .main import get_version, main from .main import get_version, main
from .wrap import _read_wrap_marker, _wrap_marker_is_stale
PASS = "pass" PASS = "pass"
WARN = "warn" WARN = "warn"
@ -188,6 +189,34 @@ def check_claude_remote_control_gate(
return None return None
def check_wrap_marker_staleness(settings_path: Path) -> CheckResult:
"""Flag a project-local ANTHROPIC_BASE_URL left by a crashed wrap session.
A crashed ``headroom wrap claude`` (SIGKILL, OOM, reboot) can leave
``.claude/settings.local.json`` pointing at a dead proxy port, hanging
every subsequent bare ``claude`` invocation in the project (issue #1768).
This checks the project-local settings file separate from the global
``~/.claude/settings.json`` :func:`check_claude_routing` inspects.
"""
name = "wrap_marker"
marker = _read_wrap_marker(settings_path)
if marker is None:
return CheckResult(name=name, status=SKIP, summary="no wrap marker found")
if not _wrap_marker_is_stale(marker):
return CheckResult(
name=name, status=PASS, summary=f"live wrap session (pid {marker.get('pid')})"
)
return CheckResult(
name=name,
status=WARN,
summary=(
f"stale ANTHROPIC_BASE_URL from crashed wrap session "
f"(pid {marker.get('pid')}, port {marker.get('port')}) — "
"run `headroom unwrap claude` to clean it up"
),
)
def check_codex_routing(config_path: Path, port: int) -> CheckResult: def check_codex_routing(config_path: Path, port: int) -> CheckResult:
"""Is Codex configured to route through the proxy? """Is Codex configured to route through the proxy?
@ -419,6 +448,7 @@ def doctor(port: int, emit_json: bool) -> None:
check_proxy_liveness(livez, base_url), check_proxy_liveness(livez, base_url),
check_version_drift(livez, installed), check_version_drift(livez, installed),
check_claude_routing(claude_settings_path(), port), check_claude_routing(claude_settings_path(), port),
check_wrap_marker_staleness(Path.cwd() / ".claude" / "settings.local.json"),
check_codex_routing(codex_config_path(), port), check_codex_routing(codex_config_path(), port),
check_shell_env(os.environ, port), check_shell_env(os.environ, port),
check_savings(stats, savings_path()), check_savings(stats, savings_path()),

View file

@ -846,12 +846,94 @@ def _claude_wrap_base_url_env_key(*, foundry_mode: bool = False, vertex_mode: bo
return "ANTHROPIC_BASE_URL" return "ANTHROPIC_BASE_URL"
def _wrap_marker_path(settings_path: Path) -> Path:
"""Sidecar marker path for a given settings.local.json path.
Kept out of settings.local.json itself so Headroom's own bookkeeping never
shows up as a stray key inside a file Claude Code's config loader parses.
"""
return settings_path.parent / ".headroom_wrap_marker.json"
def _write_wrap_marker(settings_path: Path, *, port: int, key: str, previous: str | None) -> None:
"""Best-effort record of which (pid, port, key) wrote the base_url entry.
Lets a later wrap/doctor/unwrap invocation tell a stale leftover (writer
process is dead or its PID was recycled) from a still-live wrap session,
and recover the true prior value (issue #1768) instead of guessing.
"""
try:
ident = _proc_identity(os.getpid())
payload = {
"pid": os.getpid(),
"start_src": ident[0] if ident else None,
"start_time": ident[1] if ident else None,
"port": port,
"key": key,
"previous": previous,
}
_write_text(_wrap_marker_path(settings_path), json.dumps(payload))
except OSError:
pass
def _read_wrap_marker(settings_path: Path) -> dict[str, Any] | None:
marker = _wrap_marker_path(settings_path)
try:
rec = json.loads(_read_text(marker))
except (OSError, ValueError):
return None
return rec if isinstance(rec, dict) else None
def _wrap_marker_is_stale(marker: dict[str, Any]) -> bool:
"""True if ``marker`` describes a writer that is provably gone.
Missing/invalid pid, a dead pid, or a live pid whose recorded identity no
longer matches (PID reuse) all count as stale the entry it describes was
left behind by a wrap session that no longer exists.
"""
pid = marker.get("pid")
if not isinstance(pid, int):
return True
if not _pid_alive(pid):
return True
return _identity_mismatch(marker.get("start_src"), marker.get("start_time"), pid)
def _clear_wrap_marker(settings_path: Path, *, key: str) -> None:
marker = _read_wrap_marker(settings_path)
if marker is not None and marker.get("key") == key:
_wrap_marker_path(settings_path).unlink(missing_ok=True)
def _check_and_clear_stale_wrap_marker(settings_path: Path, *, key: str) -> str | None:
"""If a stale wrap marker for ``key`` exists, restore its recorded prior
value and clear the marker. Returns the restored value, or None if there
was nothing stale to clean up.
Called before writing a fresh base_url entry so a crashed wrap session's
leftover doesn't get treated as this session's own state to restore later.
"""
marker = _read_wrap_marker(settings_path)
if marker is None or marker.get("key") != key or not _wrap_marker_is_stale(marker):
return None
previous = marker.get("previous")
click.echo(
f"headroom: clearing stale {key} left by crashed wrap session (pid {marker.get('pid')})",
err=True,
)
_restore_claude_wrap_base_url(previous, settings_path=settings_path, _key_override=key)
return previous
def _write_claude_wrap_base_url( def _write_claude_wrap_base_url(
proxy_url: str, proxy_url: str,
*, *,
foundry_mode: bool = False, foundry_mode: bool = False,
vertex_mode: bool = False, vertex_mode: bool = False,
settings_path: Path | None = None, settings_path: Path | None = None,
port: int | None = None,
) -> str | None: ) -> str | None:
"""Persist proxy URL into project-local settings env key for daemon child inheritance. """Persist proxy URL into project-local settings env key for daemon child inheritance.
@ -863,6 +945,10 @@ def _write_claude_wrap_base_url(
initial launch routes through the Headroom proxy without touching the initial launch routes through the Headroom proxy without touching the
global user settings file or affecting sessions in other projects. Returns global user settings file or affecting sessions in other projects. Returns
the previous value so the caller can restore it on exit (issue #951). the previous value so the caller can restore it on exit (issue #951).
When ``port`` is given, also stamps a sidecar marker recording this
process's identity and the previous value, so a later crash can be
detected and self-healed (issue #1768).
""" """
path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") path = settings_path or (Path.cwd() / ".claude" / "settings.local.json")
payload: dict[str, Any] = {} payload: dict[str, Any] = {}
@ -880,6 +966,8 @@ def _write_claude_wrap_base_url(
payload["env"] = env_map payload["env"] = env_map
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
_write_text(path, json.dumps(payload, indent=2) + "\n") _write_text(path, json.dumps(payload, indent=2) + "\n")
if port is not None:
_write_wrap_marker(path, port=port, key=key, previous=previous)
return previous return previous
@ -889,16 +977,22 @@ def _restore_claude_wrap_base_url(
foundry_mode: bool = False, foundry_mode: bool = False,
vertex_mode: bool = False, vertex_mode: bool = False,
settings_path: Path | None = None, settings_path: Path | None = None,
_key_override: str | None = None,
) -> None: ) -> None:
"""Restore (or remove) the env key written by _write_claude_wrap_base_url. """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 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 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 ``previous`` is None the key is removed; when it has a value it is
restored preserving any URL the project already had set. restored preserving any URL the project already had set. Also clears
this key's sidecar wrap marker, if any (issue #1768).
""" """
path = settings_path or (Path.cwd() / ".claude" / "settings.local.json") path = settings_path or (Path.cwd() / ".claude" / "settings.local.json")
key = _key_override or _claude_wrap_base_url_env_key(
foundry_mode=foundry_mode, vertex_mode=vertex_mode
)
if not path.exists(): if not path.exists():
_clear_wrap_marker(path, key=key)
return return
try: try:
payload = json.loads(_read_text(path)) payload = json.loads(_read_text(path))
@ -909,9 +1003,9 @@ def _restore_claude_wrap_base_url(
env_map = payload.get("env") env_map = payload.get("env")
if not isinstance(env_map, dict): if not isinstance(env_map, dict):
return return
key = _claude_wrap_base_url_env_key(foundry_mode=foundry_mode, vertex_mode=vertex_mode)
if previous is None: if previous is None:
if key not in env_map: if key not in env_map:
_clear_wrap_marker(path, key=key)
return return
del env_map[key] del env_map[key]
if env_map: if env_map:
@ -925,6 +1019,7 @@ def _restore_claude_wrap_base_url(
_write_text(path, json.dumps(payload, indent=2) + "\n") _write_text(path, json.dumps(payload, indent=2) + "\n")
else: else:
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
_clear_wrap_marker(path, key=key)
def _setup_headroom_mcp( def _setup_headroom_mcp(
@ -3072,26 +3167,33 @@ def _pid_alive(pid: int) -> bool:
return pid_alive(pid) return pid_alive(pid)
def _identity_mismatch(src: Any, recorded: Any, pid: int) -> bool:
"""True only if ``pid``'s current identity *provably* differs from the
recorded ``(src, recorded)`` identity (i.e. the PID was recycled).
Conservative by design: any uncertainty (unknown/legacy identity, unknown
start time, mismatched source) returns ``False`` never claim a mismatch
without proof, since the caller uses this to decide whether to trust or
discard state tied to a live PID.
"""
if not isinstance(src, str) or not isinstance(recorded, int | float):
return False # legacy / identity-less record — can't tell
ident = _proc_identity(pid)
if ident is None or ident[0] != src:
return False # can't compare like-for-like — don't claim mismatch
# Start times are stable per process; >1s apart means a different process.
return abs(ident[1] - float(recorded)) > 1.0
def _marker_pid_reused(marker: Path, pid: int) -> bool: def _marker_pid_reused(marker: Path, pid: int) -> bool:
"""True only if the live ``pid`` is *provably* a different process than the """True only if the live ``pid`` is *provably* a different process than the
one that wrote ``marker`` (i.e. the PID was recycled after a crash). one that wrote ``marker`` (i.e. the PID was recycled after a crash).
Conservative by design: any uncertainty (legacy marker, unknown start time,
mismatched source) returns ``False`` so a real client is never pruned.
""" """
try: try:
rec = json.loads(_read_text(marker)) rec = json.loads(_read_text(marker))
except (OSError, ValueError): except (OSError, ValueError):
return False return False
src = rec.get("start_src") return _identity_mismatch(rec.get("start_src"), rec.get("start_time"), pid)
recorded = rec.get("start_time")
if not isinstance(src, str) or not isinstance(recorded, int | float):
return False # legacy / identity-less marker — can't tell
ident = _proc_identity(pid)
if ident is None or ident[0] != src:
return False # can't compare like-for-like — don't prune
# Start times are stable per process; >1s apart means a different process.
return abs(ident[1] - float(recorded)) > 1.0
def _live_proxy_clients(port: int, *, exclude_self: bool = True) -> list[int]: def _live_proxy_clients(port: int, *, exclude_self: bool = True) -> list[int]:
@ -3600,6 +3702,10 @@ def claude(
_register_proxy_client(port) _register_proxy_client(port)
signal.signal(signal.SIGINT, _ignore_child_sigint) signal.signal(signal.SIGINT, _ignore_child_sigint)
signal.signal(signal.SIGTERM, cleanup) signal.signal(signal.SIGTERM, cleanup)
if hasattr(signal, "SIGHUP"):
# Terminal close / tmux kill-session sends SIGHUP, not SIGTERM — without
# this, the finally block's base_url restore never runs (issue #1768).
signal.signal(signal.SIGHUP, cleanup)
# Memory sync BEFORE proxy startup — sync headroom DB ↔ Claude's files # Memory sync BEFORE proxy startup — sync headroom DB ↔ Claude's files
if memory: if memory:
@ -3764,6 +3870,13 @@ def claude(
# daemon's environment) also route through Headroom. # daemon's environment) also route through Headroom.
_settings_vertex[0] = bool(use_vertex) _settings_vertex[0] = bool(use_vertex)
_settings_foundry[0] = bool(foundry_upstream) and not _settings_vertex[0] _settings_foundry[0] = bool(foundry_upstream) and not _settings_vertex[0]
_wrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"
_check_and_clear_stale_wrap_marker(
_wrap_settings_path,
key=_claude_wrap_base_url_env_key(
foundry_mode=_settings_foundry[0], vertex_mode=_settings_vertex[0]
),
)
_saved_base_url[0] = _write_claude_wrap_base_url( _saved_base_url[0] = _write_claude_wrap_base_url(
( (
_foundry_proxy_url(proxy_url) _foundry_proxy_url(proxy_url)
@ -3774,6 +3887,8 @@ def claude(
), ),
foundry_mode=_settings_foundry[0], foundry_mode=_settings_foundry[0],
vertex_mode=_settings_vertex[0], vertex_mode=_settings_vertex[0],
settings_path=_wrap_settings_path,
port=port,
) )
# Per-project savings attribution: tag every request with the launch # Per-project savings attribution: tag every request with the launch
@ -3817,6 +3932,7 @@ def claude(
_saved_base_url[0], _saved_base_url[0],
foundry_mode=_settings_foundry[0], foundry_mode=_settings_foundry[0],
vertex_mode=_settings_vertex[0], vertex_mode=_settings_vertex[0],
settings_path=_wrap_settings_path,
) )
cleanup() cleanup()
@ -3884,9 +4000,19 @@ def unwrap_claude(
else: else:
click.echo(" Kept rtk Claude hooks (--keep-rtk).") click.echo(" Kept rtk Claude hooks (--keep-rtk).")
_restore_claude_wrap_base_url(None) _unwrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"
_restore_claude_wrap_base_url(None, foundry_mode=True) for _foundry, _vertex in ((False, False), (True, False), (False, True)):
_restore_claude_wrap_base_url(None, vertex_mode=True) _key = _claude_wrap_base_url_env_key(foundry_mode=_foundry, vertex_mode=_vertex)
_marker = _read_wrap_marker(_unwrap_settings_path)
_prior = (
_marker.get("previous") if _marker is not None and _marker.get("key") == _key else None
)
_restore_claude_wrap_base_url(
_prior,
foundry_mode=_foundry,
vertex_mode=_vertex,
settings_path=_unwrap_settings_path,
)
click.echo() click.echo()
click.echo("✓ Claude is no longer durably wrapped by Headroom.") click.echo("✓ Claude is no longer durably wrapped by Headroom.")

View file

@ -223,10 +223,26 @@ def test_unwrap_claude_restores_all_base_url_modes(runner: CliRunner) -> None:
) )
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
settings_path = Path.cwd() / ".claude" / "settings.local.json"
assert restore_calls == [ assert restore_calls == [
{"previous": None}, {
{"previous": None, "foundry_mode": True}, "previous": None,
{"previous": None, "vertex_mode": True}, "foundry_mode": False,
"vertex_mode": False,
"settings_path": settings_path,
},
{
"previous": None,
"foundry_mode": True,
"vertex_mode": False,
"settings_path": settings_path,
},
{
"previous": None,
"foundry_mode": False,
"vertex_mode": True,
"settings_path": settings_path,
},
] ]

View file

@ -190,3 +190,94 @@ def test_write_restore_roundtrip(tmp_path: Path) -> None:
assert "ANTHROPIC_BASE_URL" not in payload.get("env", {}) assert "ANTHROPIC_BASE_URL" not in payload.get("env", {})
assert payload["env"]["OTHER"] == "x" assert payload["env"]["OTHER"] == "x"
assert payload["model"] == "opus" assert payload["model"] == "opus"
# --- stale wrap marker (issue #1768) --------------------------------------
def _marker(tmp_path: Path) -> Path:
return wrap_cli._wrap_marker_path(_settings(tmp_path))
def test_write_with_port_creates_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
assert marker["port"] == 8787
assert marker["key"] == "ANTHROPIC_BASE_URL"
assert marker["previous"] is None
assert marker["pid"] > 0
def test_write_without_port_skips_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
assert not _marker(tmp_path).exists()
def test_restore_clears_marker_for_matching_key(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
assert _marker(tmp_path).exists()
wrap_cli._restore_claude_wrap_base_url(None, settings_path=path)
assert not _marker(tmp_path).exists()
def test_wrap_marker_is_stale_when_pid_missing() -> None:
assert wrap_cli._wrap_marker_is_stale({}) is True
def test_wrap_marker_is_stale_when_pid_dead(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
marker["pid"] = 999_999_999 # astronomically unlikely to be a live pid
assert wrap_cli._wrap_marker_is_stale(marker) is True
def test_wrap_marker_is_not_stale_for_live_pid(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
assert wrap_cli._wrap_marker_is_stale(marker) is False
def test_wrap_marker_is_stale_when_pid_reused(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
marker["start_time"] = (marker["start_time"] or 0) - 10_000 # fabricate a mismatched identity
assert wrap_cli._wrap_marker_is_stale(marker) is True
def test_check_and_clear_stale_wrap_marker_restores_previous(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"
)
wrap_cli._write_wrap_marker(
path, port=8787, key="ANTHROPIC_BASE_URL", previous="http://old.proxy:9000"
)
marker = json.loads(_marker(tmp_path).read_text(encoding="utf-8"))
marker["pid"] = 999_999_999
_marker(tmp_path).write_text(json.dumps(marker), encoding="utf-8")
restored = wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL")
assert restored == "http://old.proxy:9000"
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://old.proxy:9000"
assert not _marker(tmp_path).exists()
def test_check_and_clear_stale_wrap_marker_leaves_live_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
restored = wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL")
assert restored is None
assert _marker(tmp_path).exists()
def test_check_and_clear_stale_wrap_marker_noop_when_no_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
assert wrap_cli._check_and_clear_stale_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None

View file

@ -0,0 +1,52 @@
from __future__ import annotations
import json
from pathlib import Path
from headroom.cli import doctor as doctor_cli
from headroom.cli import wrap as wrap_cli
def _settings(tmp_path: Path) -> Path:
return tmp_path / ".claude" / "settings.local.json"
def test_doctor_skips_with_no_marker(tmp_path: Path) -> None:
result = doctor_cli.check_wrap_marker_staleness(_settings(tmp_path))
assert result.status == doctor_cli.SKIP
def test_doctor_passes_with_live_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
result = doctor_cli.check_wrap_marker_staleness(path)
assert result.status == doctor_cli.PASS
def test_doctor_flags_stale_wrap_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path, port=8787)
marker_path = wrap_cli._wrap_marker_path(path)
marker = json.loads(marker_path.read_text(encoding="utf-8"))
marker["pid"] = 999_999_999
marker_path.write_text(json.dumps(marker), encoding="utf-8")
result = doctor_cli.check_wrap_marker_staleness(path)
assert result.status == doctor_cli.WARN
assert "999999999" in result.summary
assert "headroom unwrap claude" in result.summary
def test_claude_command_registers_sighup_next_to_sigterm() -> None:
"""`claude()` must catch SIGHUP (terminal close) the same way it catches
SIGTERM, or a crashed-by-terminal-close wrap session never restores its
base_url (issue #1768). Full signal delivery isn't practical to exercise
via CliRunner (would require spawning/killing a real subprocess), so this
asserts the registration is present in claude()'s source, guarded for
platforms without SIGHUP.
"""
import inspect
src = inspect.getsource(wrap_cli.claude.callback)
assert 'hasattr(signal, "SIGHUP")' in src
assert "signal.signal(signal.SIGHUP, cleanup)" in src