mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
ea0115cbdb
commit
8537e2cf60
2 changed files with 558 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
312
tests/test_cli/test_wrap_dead_marker_selfheal.py
Normal file
312
tests/test_cli/test_wrap_dead_marker_selfheal.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue