fix(wrap): self-heal a stale ANTHROPIC_BASE_URL left by a dead proxy (#2223)

## Description

`headroom wrap claude` persists `ANTHROPIC_BASE_URL=<proxy>` into
project-local `.claude/settings.local.json`. This is required: Claude
Code's cc-daemon spawn-forks conversation workers that read settings
fresh rather than inherit env, so the URL cannot just live in the child
process env.

When the proxy then dies via a **hard reboot / SIGKILL**, no
signal/atexit cleanup fires, so the stale URL lingers and bricks a later
**bare `claude`** with ConnectionRefused (#2221). #1768's mitigations
(SIGHUP, next-`wrap` self-heal, doctor WARN) don't cover "reboot → bare
`claude`", and — the key gap — `wrap` installed no hook of its own, so
for a user who only ever ran `wrap claude` (never `init claude`) there
was nothing to clean it up.

`wrap claude` now installs a **SessionStart-only** self-heal hook
(removed again on `unwrap`) that clears the persisted base URL **iff the
recorded proxy port fails a retry-hardened liveness probe**. A
responding proxy is never cleared, and the retry (3 attempts ~250 ms
apart, alive on first success) keeps a transient blip from clearing a
live session mid-run. Because workers read settings fresh per
conversation, clearing at session start unblocks the current session
too, not only the next.

## Design note / assumption (for maintainer confirmation)

This relies on **the SessionStart hook completing before the first
cc-daemon conversation worker reads `settings.local.json`**. That
ordering lives in Claude Code, not this repo; it is grounded in the
documented spawn-fresh-read model (the same reason the URL must be
persisted at all). Raised on the issue for confirmation. The truly
launcher-agnostic fix would be upstream — Claude Code falling back to
the real upstream when its configured base URL is unreachable — which
would make any stale local URL harmless.

## Type of Change

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

## Changes Made

- `headroom/cli/wrap.py`:
- `_wrap_proxy_alive(port, attempts=3, delay=0.25)` — retry-hardened
liveness (alive on first success, dead only if all fail).
- `_check_and_clear_dead_wrap_marker` — port is authoritative (survives
PID reuse after reboot); a single probe decides; a responding proxy is
never cleared; falls back to PID staleness only for port-less markers.
- `_ensure_claude_wrap_selfheal_hook` /
`_remove_claude_wrap_selfheal_hook` — install on `wrap claude`, remove
on `unwrap`; **SessionStart-only** (never PreToolUse), idempotent,
preserves the `env` block and unrelated/user hooks.
  - hidden `wrap selfheal` command the hook invokes.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff`)
- [x] Type checking passes (`mypy`)
- [x] New tests added for new functionality

### Test Output

```text
$ pytest tests/test_cli/test_wrap_dead_marker_selfheal.py
22 passed

$ pytest <related wrap/unwrap suites>
59 passed, 1 failed   # the 1 failure (test_wrap_marker_is_stale_when_pid_reused)
                      # is PRE-EXISTING + unrelated — fails identically on clean main
                      # (macOS _proc_identity returns None); this PR touches neither
                      # _wrap_marker_is_stale nor _identity_mismatch.

$ ruff check / mypy headroom/cli/wrap.py   # clean
```

## Real Behavior Proof

- Environment: macOS (Darwin), Python in a uv venv, branch
`feat/wrap-stale-url-selfheal` off `main`.
- Exact command / steps: `pytest
tests/test_cli/test_wrap_dead_marker_selfheal.py` exercises: `wrap
claude` writes a SessionStart-only self-heal hook into
`settings.local.json` (idempotent, not on PreToolUse); `unwrap` removes
it (keeping unrelated hooks); the `wrap selfheal` command clears a
dead-port marker's base URL; and — bound to a REAL listening socket — a
live proxy's marker is never cleared, including when a single probe
transiently fails but the retry succeeds.
- Observed result: dead-proxy marker → base URL restored to its prior
value; live-proxy marker (real socket) → preserved; no marker / no
settings file / port-less marker → no-op, no exception. All 22 pass.
- Not tested: the actual Claude Code hook-vs-worker execution ordering
(upstream, not in this repo) — see the Design note; the fix is correct
given that documented model.

## 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 — N/A
(internal wrap behavior)
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md — happy to add an entry if
preferred.

## Additional Notes

- Scoped to the `wrap claude` project-local path (the reported
scenario). The opt-in cc-switch reconciler writes `ANTHROPIC_BASE_URL`
into the *global* `~/.claude/settings.json` with no restore today — a
separate, lower-frequency gap I can follow up on if wanted.

---------

Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
Zhenjia ZHOU 2026-07-16 03:58:10 +08:00 committed by GitHub
parent ea0115cbdb
commit 8537e2cf60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 558 additions and 0 deletions

View file

@ -799,6 +799,11 @@ _HEADROOM_HOOK_MARKERS = ("rtk-rewrite", "headroom-init-claude")
# (GH #746), paired with init/wrap setting it.
_HEADROOM_ENV_KEYS = ("ANTHROPIC_BASE_URL", "ENABLE_TOOL_SEARCH")
# Stable marker embedded in the SessionStart self-heal hook that ``wrap claude``
# installs (issue #2221). Lets that hook be found (idempotent install) and
# removed (unwrap) by its command string.
_WRAP_SELFHEAL_HOOK_MARKER = "headroom-wrap-selfheal"
def _remove_claude_rtk_hooks(settings_path: Path | None = None) -> bool:
"""Remove Headroom-managed entries from Claude settings.json.
@ -1004,6 +1009,43 @@ def _wrap_marker_is_stale(marker: dict[str, Any]) -> bool:
return _identity_mismatch(marker.get("start_src"), marker.get("start_time"), pid)
def _wrap_proxy_alive(port: int, *, attempts: int = 3, delay: float = 0.25) -> bool:
"""Retry-hardened liveness probe for a wrap proxy ``port`` (issue #2221).
A single 1s TCP connect can spuriously fail against a live-but-busy proxy
(full accept queue, scheduler delay). Clearing a live session's base_url on
such a transient blip stops its cc-daemon workers from routing through the
proxy mid-session, so the proxy is declared ALIVE on the FIRST successful
connect and DEAD only when all ``attempts`` (spaced ~``delay`` s apart)
fail. Returns early on the first success, so a live proxy pays no delay.
"""
for attempt in range(attempts):
if _check_proxy(port):
return True
if attempt < attempts - 1:
time.sleep(delay)
return False
def _wrap_marker_proxy_is_dead(marker: dict[str, Any]) -> bool:
"""True if ``marker`` records a proxy ``port`` that no longer accepts
connections.
Port liveness is the authoritative signal for a wrap session that vanished
without running its cleanup (hard reboot / SIGKILL, issue #2221): the
recorded PID is unreliable because a reboot can recycle it onto an
unrelated live process, so a PID that still looks alive does not prove the
proxy is up. A marker with no recorded port returns False here (fall back
to PID-based staleness); a marker whose port IS responding is a live
session and must never be treated as dead. Uses the retry-hardened
``_wrap_proxy_alive`` so a momentary blip never reads as dead.
"""
port = marker.get("port")
if not isinstance(port, int):
return False
return not _wrap_proxy_alive(port)
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:
@ -1030,6 +1072,191 @@ def _check_and_clear_stale_wrap_marker(settings_path: Path, *, key: str) -> str
return previous
def _check_and_clear_dead_wrap_marker(settings_path: Path, *, key: str) -> str | None:
"""Session-start self-heal for a wrap base_url left by a dead proxy (#2221).
Like ``_check_and_clear_stale_wrap_marker`` (PID/identity based), but also
clears when the marker's recorded proxy PORT is no longer accepting
connections even if its PID still looks alive. A hard reboot / SIGKILL
runs no signal/atexit cleanup, so the ``ANTHROPIC_BASE_URL`` persisted for
cc-daemon conversation workers keeps pointing at a dead proxy and bricks a
later bare ``claude`` with ConnectionRefused. Because those workers read
settings.local.json fresh per conversation, clearing it at session start
(before any worker reads it) also unblocks the current session.
CRITICAL: a marker whose port IS responding is a live wrapped session and
is never cleared. Returns the restored prior value, or None when there was
nothing dead to clean up.
"""
marker = _read_wrap_marker(settings_path)
if marker is None or marker.get("key") != key:
return None
port = marker.get("port")
if isinstance(port, int):
# Port is the authoritative signal (it survives PID reuse after a
# reboot). A single retry-hardened probe decides it: a responding port
# is a live session (never cleared); only a port that fails the whole
# retry window is dead. One probe here — no correlated double check.
if _wrap_proxy_alive(port):
return None
elif not _wrap_marker_is_stale(marker):
# No recorded port → fall back to PID-based staleness.
return None
previous = marker.get("previous")
click.echo(
f"headroom: clearing stale {key} left by a proxy that is no longer "
f"running (issue #2221); restoring prior value",
err=True,
)
_restore_claude_wrap_base_url(previous, settings_path=settings_path, _key_override=key)
return previous
def _selfheal_dead_wrap_base_url() -> None:
"""Clear a project-local wrap base_url left pointing at a dead proxy (#2221).
Runs at every Claude session start via the SessionStart hook that
``wrap claude`` installs. When ``wrap claude`` persists
``ANTHROPIC_BASE_URL=<proxy>`` into ``.claude/settings.local.json`` and the
proxy later dies via hard reboot / SIGKILL, no signal/atexit cleanup fires,
so the stale URL lingers and bricks a later bare ``claude`` with
ConnectionRefused. cc-daemon reads settings.local.json fresh per
conversation, so clearing it here before any conversation worker reads
it also unblocks the current session.
Must never raise: a broken self-heal must not break session startup.
"""
try:
settings_path = Path.cwd() / ".claude" / "settings.local.json"
for key in (
_claude_wrap_base_url_env_key(),
_claude_wrap_base_url_env_key(foundry_mode=True),
_claude_wrap_base_url_env_key(vertex_mode=True),
):
_check_and_clear_dead_wrap_marker(settings_path, key=key)
except Exception: # noqa: BLE001 - hook must never break session startup
pass
def _wrap_selfheal_hook_command() -> str:
"""Command string for the SessionStart self-heal hook (mirrors init hooks)."""
from headroom.cli.init import _command_string
from headroom.install.runtime import resolve_headroom_command
return _command_string(
[*resolve_headroom_command(), "wrap", "selfheal", "--marker", _WRAP_SELFHEAL_HOOK_MARKER]
)
def _ensure_claude_wrap_selfheal_hook(settings_path: Path) -> None:
"""Install a SessionStart-only self-heal hook into settings.local.json (#2221).
``wrap claude`` writes the proxy base_url + a sidecar marker but installs no
hook of its own, so a session that only ran ``wrap`` (never ``init``) had no
reader to clear a dead-proxy URL the reported bug. This pairs the marker
with a SessionStart hook that runs the hidden ``wrap selfheal`` command.
SessionStart ONLY (never PreToolUse): the self-heal must not run per Bash
call mid-session, where a transient probe blip could clear a live session.
Idempotent an existing entry carrying the marker is not duplicated.
"""
payload: dict[str, Any] = {}
if settings_path.exists():
try:
payload = json.loads(_read_text(settings_path))
except (OSError, json.JSONDecodeError):
payload = {}
if not isinstance(payload, dict):
payload = {}
hooks = dict(payload.get("hooks") or {}) if isinstance(payload.get("hooks"), dict) else {}
entries = (
list(hooks.get("SessionStart") or []) if isinstance(hooks.get("SessionStart"), list) else []
)
already = any(
isinstance(entry, dict)
and isinstance(entry.get("hooks"), list)
and any(
isinstance(item, dict) and _WRAP_SELFHEAL_HOOK_MARKER in str(item.get("command", ""))
for item in entry["hooks"]
)
for entry in entries
)
if already:
return
entries.append(
{
"matcher": "startup|resume",
"hooks": [
{
"type": "command",
"command": _wrap_selfheal_hook_command(),
"timeout": 10,
}
],
}
)
hooks["SessionStart"] = entries
payload["hooks"] = hooks
settings_path.parent.mkdir(parents=True, exist_ok=True)
_write_text(settings_path, json.dumps(payload, indent=2) + "\n")
def _remove_claude_wrap_selfheal_hook(settings_path: Path) -> bool:
"""Remove the SessionStart self-heal hook that ``wrap claude`` installed (#2221).
Mirrors ``_remove_claude_rtk_hooks`` but matches only the wrap self-heal
marker in the project-local settings.local.json. Returns True if anything
was removed. Unrelated hooks and user-authored entries are left untouched.
"""
if not settings_path.exists():
return False
try:
payload = json.loads(_read_text(settings_path))
except (OSError, json.JSONDecodeError):
return False
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: list[Any] = []
for entry in entries:
if isinstance(entry, dict) and isinstance(entry.get("hooks"), list):
kept = [
item
for item in entry["hooks"]
if not (
isinstance(item, dict)
and _WRAP_SELFHEAL_HOOK_MARKER in str(item.get("command", ""))
)
]
if len(kept) != len(entry["hooks"]):
changed = True
if kept:
retained.append({**entry, "hooks": kept})
continue
retained.append(entry)
if retained:
hooks[event] = retained
else:
del hooks[event]
changed = True
if not changed:
return False
if hooks:
payload["hooks"] = hooks
else:
payload.pop("hooks", None)
if payload:
_write_text(settings_path, json.dumps(payload, indent=2) + "\n")
else:
settings_path.unlink(missing_ok=True)
return True
def _write_claude_wrap_base_url(
proxy_url: str,
*,
@ -3890,6 +4117,19 @@ def unwrap() -> None:
"""Undo durable Headroom wrapping for supported tools."""
@wrap.command("selfheal", hidden=True)
@click.option("--marker", default=None, hidden=True)
def wrap_selfheal(marker: str | None) -> None:
"""Session-start self-heal for a wrap base_url left by a dead proxy (#2221).
Installed as a SessionStart-only hook by ``wrap claude`` so a session that
only ran ``wrap`` (never ``init``) still recovers a stale ``ANTHROPIC_BASE_URL``
when its proxy died without cleanup. Best-effort and never raises.
"""
del marker
_selfheal_dead_wrap_base_url()
# =============================================================================
# Claude Code
# =============================================================================
@ -4277,6 +4517,10 @@ def claude(
settings_path=_wrap_settings_path,
port=port,
)
# Issue #2221: pair the marker just written with a reader. wrap installs
# no hook of its own, so a session that only ran `wrap` (never `init`)
# had nothing to clear a dead-proxy base_url. SessionStart-only.
_ensure_claude_wrap_selfheal_hook(_wrap_settings_path)
# Per-project savings attribution: tag every request with the launch
# directory's name via X-Headroom-Project (user override wins).
@ -4395,6 +4639,8 @@ def unwrap_claude(
click.echo(" Kept rtk Claude hooks (--keep-rtk).")
_unwrap_settings_path = Path.cwd() / ".claude" / "settings.local.json"
if _remove_claude_wrap_selfheal_hook(_unwrap_settings_path):
click.echo(" Removed Headroom wrap self-heal SessionStart hook (issue #2221).")
for _foundry, _vertex in ((False, False), (True, False), (False, True)):
_key = _claude_wrap_base_url_env_key(foundry_mode=_foundry, vertex_mode=_vertex)
_marker = _read_wrap_marker(_unwrap_settings_path)

View file

@ -0,0 +1,312 @@
"""Session-start self-heal for a wrap base_url left by a dead proxy (issue #2221).
`headroom wrap claude` persists ANTHROPIC_BASE_URL=<proxy> into project-local
.claude/settings.local.json so cc-daemon conversation workers (which read
settings fresh) also route through the proxy. When the proxy dies via hard
reboot / SIGKILL no cleanup fires, so the stale URL lingers and bricks a later
bare `claude`. These tests cover the port-liveness self-heal that clears it.
"""
from __future__ import annotations
import json
import socket
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from click.testing import CliRunner
from headroom.cli import wrap as wrap_cli
def _settings(tmp_path: Path) -> Path:
return tmp_path / ".claude" / "settings.local.json"
def _marker(tmp_path: Path) -> Path:
return wrap_cli._wrap_marker_path(_settings(tmp_path))
def _closed_port() -> int:
"""A port that is (almost certainly) not accepting connections."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
return port
@contextmanager
def _listening_port() -> Iterator[int]:
"""A real bound+listening socket; its port answers TCP connects (live proxy)."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("127.0.0.1", 0))
s.listen(1)
try:
yield s.getsockname()[1]
finally:
s.close()
# --- _wrap_marker_proxy_is_dead -------------------------------------------
def test_proxy_is_dead_when_port_not_listening() -> None:
assert wrap_cli._wrap_marker_proxy_is_dead({"port": _closed_port()}) is True
def test_proxy_is_not_dead_when_port_listening() -> None:
with _listening_port() as port:
assert wrap_cli._wrap_marker_proxy_is_dead({"port": port}) is False
def test_proxy_is_not_dead_when_no_port_recorded() -> None:
# No port → fall back to PID-based staleness, so not "dead" by port here.
assert wrap_cli._wrap_marker_proxy_is_dead({}) is False
# --- _check_and_clear_dead_wrap_marker ------------------------------------
def test_dead_port_restores_previous_value(tmp_path: Path) -> None:
path = _settings(tmp_path)
# Live PID (this process) but a dead port — the reboot/SIGKILL case where
# PID liveness lies and only the port tells the truth.
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(
path, port=_closed_port(), key="ANTHROPIC_BASE_URL", previous="http://old.proxy:9000"
)
restored = wrap_cli._check_and_clear_dead_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_dead_port_removes_key_when_no_previous(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(path, port=_closed_port(), key="ANTHROPIC_BASE_URL", previous=None)
restored = wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL")
assert restored is None
# env held only our key, so the now-empty settings file is removed entirely.
assert not path.exists()
assert not _marker(tmp_path).exists()
def test_live_port_is_never_cleared(tmp_path: Path) -> None:
path = _settings(tmp_path)
with _listening_port() as port:
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(path, port=port, key="ANTHROPIC_BASE_URL", previous=None)
restored = wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL")
assert restored is None
# Live wrapped session preserved: base URL and marker both intact.
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert _marker(tmp_path).exists()
def test_noop_when_no_marker(tmp_path: Path) -> None:
path = _settings(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(json.dumps({"env": {"ANTHROPIC_BASE_URL": "http://x"}}), encoding="utf-8")
assert wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None
# Untouched — no marker means no evidence the URL is Headroom's to clear.
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://x"
def test_noop_when_no_settings_file(tmp_path: Path) -> None:
path = _settings(tmp_path)
assert wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None
def test_noop_when_marker_key_mismatch(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(
path, port=_closed_port(), key="ANTHROPIC_VERTEX_BASE_URL", previous=None
)
# Asked about the default key; marker is for the vertex key → no-op.
assert wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None
assert _marker(tmp_path).exists()
# --- retry-hardened liveness (_wrap_proxy_alive) --------------------------
def test_proxy_alive_returns_true_on_first_success() -> None:
with _listening_port() as port:
assert wrap_cli._wrap_proxy_alive(port) is True
def test_proxy_alive_false_only_after_all_attempts_fail() -> None:
assert wrap_cli._wrap_proxy_alive(_closed_port(), attempts=3, delay=0.01) is False
def test_proxy_alive_tolerates_single_transient_failure(monkeypatch) -> None:
# A live-but-busy proxy whose first TCP connect blips: retry must treat it
# as ALIVE, not dead, so a live session's base_url is never cleared.
calls = {"n": 0}
def _flaky(_port: int) -> bool:
calls["n"] += 1
return calls["n"] > 1 # first probe fails, second succeeds
monkeypatch.setattr(wrap_cli, "_check_proxy", _flaky)
monkeypatch.setattr(wrap_cli.time, "sleep", lambda _s: None)
assert wrap_cli._wrap_proxy_alive(1234) is True
def test_transient_blip_does_not_clear_live_marker(tmp_path: Path, monkeypatch) -> None:
# End-to-end: a marked session whose proxy blips once at session start must
# keep its base_url and marker — the retry closes the false-dead hole.
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(path, port=9191, key="ANTHROPIC_BASE_URL", previous=None)
calls = {"n": 0}
def _flaky(_port: int) -> bool:
calls["n"] += 1
return calls["n"] > 1
monkeypatch.setattr(wrap_cli, "_check_proxy", _flaky)
monkeypatch.setattr(wrap_cli.time, "sleep", lambda _s: None)
assert wrap_cli._check_and_clear_dead_wrap_marker(path, key="ANTHROPIC_BASE_URL") is None
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert _marker(tmp_path).exists()
# --- SessionStart self-heal hook install (wrap claude) --------------------
_HOOK_MARKER = "headroom-wrap-selfheal"
def test_wrap_installs_sessionstart_only_selfheal_hook(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
payload = json.loads(path.read_text(encoding="utf-8"))
session_start = payload["hooks"]["SessionStart"]
assert len(session_start) == 1
entry = session_start[0]
assert entry["matcher"] == "startup|resume"
command = entry["hooks"][0]["command"]
assert _HOOK_MARKER in command
assert "wrap selfheal" in command
assert entry["hooks"][0]["timeout"] == 10
# SessionStart ONLY — never registered on PreToolUse (defect 2 exposure).
assert "PreToolUse" not in payload["hooks"]
def test_wrap_selfheal_hook_install_is_idempotent(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
payload = json.loads(path.read_text(encoding="utf-8"))
marked = [
entry
for entry in payload["hooks"]["SessionStart"]
if any(_HOOK_MARKER in h.get("command", "") for h in entry["hooks"])
]
assert len(marked) == 1
def test_wrap_selfheal_hook_preserves_existing_hooks(tmp_path: Path) -> None:
path = _settings(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(
json.dumps(
{"hooks": {"SessionStart": [{"matcher": "startup", "hooks": [{"command": "mine"}]}]}}
),
encoding="utf-8",
)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
payload = json.loads(path.read_text(encoding="utf-8"))
commands = [h.get("command") for e in payload["hooks"]["SessionStart"] for h in e["hooks"]]
assert "mine" in commands
assert any(_HOOK_MARKER in str(c) for c in commands)
def test_unwrap_removes_selfheal_hook(tmp_path: Path) -> None:
path = _settings(tmp_path)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
assert wrap_cli._remove_claude_wrap_selfheal_hook(path) is True
if path.exists():
payload = json.loads(path.read_text(encoding="utf-8"))
commands = [
h.get("command")
for e in (payload.get("hooks", {}).get("SessionStart") or [])
for h in e.get("hooks", [])
]
assert not any(_HOOK_MARKER in str(c) for c in commands)
def test_unwrap_removal_keeps_unrelated_hooks(tmp_path: Path) -> None:
path = _settings(tmp_path)
path.parent.mkdir(parents=True)
path.write_text(
json.dumps(
{"hooks": {"SessionStart": [{"matcher": "startup", "hooks": [{"command": "mine"}]}]}}
),
encoding="utf-8",
)
wrap_cli._ensure_claude_wrap_selfheal_hook(path)
assert wrap_cli._remove_claude_wrap_selfheal_hook(path) is True
payload = json.loads(path.read_text(encoding="utf-8"))
commands = [h.get("command") for e in payload["hooks"]["SessionStart"] for h in e["hooks"]]
assert commands == ["mine"]
def test_unwrap_removal_noop_without_hook(tmp_path: Path) -> None:
path = _settings(tmp_path)
assert wrap_cli._remove_claude_wrap_selfheal_hook(path) is False
# --- self-heal CLI command (installed hook target) ------------------------
def test_selfheal_command_clears_dead_marker(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
path = _settings(tmp_path)
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(path, port=_closed_port(), key="ANTHROPIC_BASE_URL", previous=None)
result = CliRunner().invoke(wrap_cli.wrap, ["selfheal", "--marker", "headroom-wrap-selfheal"])
assert result.exit_code == 0
# env held only our key, so the now-empty settings file is removed entirely.
assert not path.exists()
assert not _marker(tmp_path).exists()
def test_selfheal_command_preserves_live_marker(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
path = _settings(tmp_path)
with _listening_port() as port:
wrap_cli._write_claude_wrap_base_url("http://127.0.0.1:8787", settings_path=path)
wrap_cli._write_wrap_marker(path, port=port, key="ANTHROPIC_BASE_URL", previous=None)
result = CliRunner().invoke(
wrap_cli.wrap, ["selfheal", "--marker", "headroom-wrap-selfheal"]
)
assert result.exit_code == 0
payload = json.loads(path.read_text(encoding="utf-8"))
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
assert _marker(tmp_path).exists()
def test_selfheal_never_raises_without_settings(tmp_path: Path, monkeypatch) -> None:
monkeypatch.chdir(tmp_path)
wrap_cli._selfheal_dead_wrap_base_url() # no .claude dir at all — silent no-op