Skip to content

Commit cf5fa64

Browse files
fix(wrap): stop same-port persistent routing during claude unwrap (headroomlabs-ai#2340) (headroomlabs-ai#2350)
## Description `headroom unwrap claude` currently removes Claude-local wrap state but can still leave Claude effectively routed through Headroom when the same port belongs to a managed persistent deployment. The command already knows how to discover same-port persistent manifests, but its stop path only kills the current pid and never uses that deployment metadata. This patch keeps the existing local settings cleanup, then applies an ownership-aware same-port audit: Claude-owned deployments are stopped through the install lifecycle path, while ambiguous same-port residue is surfaced with exact remediation instead of a false clean-success claim. Refs headroomlabs-ai#2340. ## 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 - extend the Claude unwrap stop path in `headroom/cli/wrap.py` to distinguish local pid stops, Claude-owned same-port persistent deployments, and ambiguous same-port residue - reuse the install lifecycle teardown path for Claude-owned persistent deployments instead of re-implementing supervisor cleanup - keep the existing Claude-local settings, hook, and base-url cleanup unchanged - add focused CLI regressions that prove a matching Claude-owned deployment is stopped during unwrap, ambiguous same-port residue is reported truthfully, and different-port manifests stay untouched ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_persistent.py -q`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_unwrap_claude.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [x] Manual testing performed ### Test Output ```text uv run pytest tests/test_cli/test_unwrap_claude.py tests/test_cli/test_wrap_persistent.py -q ============================= 43 passed in 0.51s ============================== uv run pytest tests/test_cli/test_unwrap_claude.py -q ============================= 13 passed in 0.41s ============================== uv run pytest tests/test_cli/test_wrap_persistent.py -q ============================= 30 passed in 0.39s ============================== uv run ruff check headroom/cli/wrap.py tests/test_cli/test_unwrap_claude.py All checks passed! uv run ruff format headroom/cli/wrap.py tests/test_cli/test_unwrap_claude.py --check 2 files already formatted ``` ## Real Behavior Proof - Environment: Windows worktree `D:\Repos\headroom-pr-2340-claude-unwrap-effective-routing` with `uv sync --extra dev` - Exact command / steps: run the focused pytest and Ruff commands above, then run a constructed `CliRunner` replay against both `D:\Repos\headroom` and this branch with the same same-port Claude-owned manifest harness - Observed result: base prints `base: exit=0; local=[8787]; deactivated=[]; stopped=[]` and still routes through the pid-only helper; head prints `head: exit=0; local=[]; deactivated=['unwrap-2340']; stopped=['unwrap-2340']` and reports `Stopped Claude-owned persistent deployment 'unwrap-2340' on port 8787.` - Not tested: a live macOS launchd deployment on this host ## 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 ## Additional Notes `CHANGELOG.md` is not applicable because Headroom derives release notes from conventional commits. Scope stays below the broader uninstall workflow in open PR `headroomlabs-ai#749`: this patch makes Claude unwrap truthful and ownership-aware, but it does not remove install artifacts or introduce a new uninstall command. --------- Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
1 parent b759990 commit cf5fa64

2 files changed

Lines changed: 254 additions & 3 deletions

File tree

headroom/cli/wrap.py

Lines changed: 149 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3186,6 +3186,143 @@ def _stop_local_proxy_for_unwrap(port: int) -> str:
31863186
return "stopped" if _kill_proxy_by_pid(pid, port) else "failed"
31873187

31883188

3189+
def _manifest_targets_claude(manifest: Any) -> bool:
3190+
targets = getattr(manifest, "targets", None)
3191+
if isinstance(targets, list) and any(
3192+
str(target).strip().lower() == "claude" for target in targets
3193+
):
3194+
return True
3195+
tool_envs = getattr(manifest, "tool_envs", None)
3196+
if isinstance(tool_envs, dict) and any(
3197+
str(name).strip().lower() == "claude" for name in tool_envs
3198+
):
3199+
return True
3200+
mutations = getattr(manifest, "mutations", None)
3201+
if isinstance(mutations, list):
3202+
for mutation in mutations:
3203+
if str(getattr(mutation, "target", "")).strip().lower() == "claude":
3204+
return True
3205+
return False
3206+
3207+
3208+
def _can_unwrap_stop_persistent_manifest(manifest: Any) -> bool:
3209+
if not _manifest_targets_claude(manifest):
3210+
return False
3211+
supervisor_kind = str(getattr(manifest, "supervisor_kind", "")).strip().lower()
3212+
return supervisor_kind in {"", "none", "service"}
3213+
3214+
3215+
def _same_port_claude_env_keys(port: int) -> list[str]:
3216+
matches: list[str] = []
3217+
for key in (
3218+
"ANTHROPIC_BASE_URL",
3219+
"ANTHROPIC_FOUNDRY_BASE_URL",
3220+
"ANTHROPIC_VERTEX_BASE_URL",
3221+
):
3222+
raw = os.environ.get(key, "").strip()
3223+
if not raw:
3224+
continue
3225+
try:
3226+
parsed = urllib.parse.urlparse(raw)
3227+
except Exception:
3228+
continue
3229+
try:
3230+
parsed_port = parsed.port
3231+
except ValueError:
3232+
continue
3233+
if parsed_port != port:
3234+
continue
3235+
host = (parsed.hostname or "").strip().lower()
3236+
if host not in {"127.0.0.1", "localhost", "::1"}:
3237+
continue
3238+
matches.append(key)
3239+
return matches
3240+
3241+
3242+
def _stop_persistent_manifest_for_claude_unwrap(manifest: Any) -> str | None:
3243+
from headroom.cli.install import _deactivate_deployment_mutations, _stop_deployment
3244+
3245+
try:
3246+
_deactivate_deployment_mutations(manifest)
3247+
_stop_deployment(manifest)
3248+
return None
3249+
except Exception as exc:
3250+
return str(exc)
3251+
3252+
3253+
def _unwrap_claude_route_cleanup(port: int) -> dict[str, Any]:
3254+
manifest = _find_persistent_manifest(port)
3255+
env_keys = _same_port_claude_env_keys(port)
3256+
if manifest is not None:
3257+
if _can_unwrap_stop_persistent_manifest(manifest):
3258+
error = _stop_persistent_manifest_for_claude_unwrap(manifest)
3259+
if error is None:
3260+
return {
3261+
"kind": "persistent_stopped",
3262+
"manifest": manifest,
3263+
"env_keys": env_keys,
3264+
}
3265+
return {
3266+
"kind": "persistent_failed",
3267+
"manifest": manifest,
3268+
"env_keys": env_keys,
3269+
"error": error,
3270+
}
3271+
return {
3272+
"kind": "persistent_residue",
3273+
"manifest": manifest,
3274+
"env_keys": env_keys,
3275+
}
3276+
return {
3277+
"kind": "local",
3278+
"status": _stop_local_proxy_for_unwrap(port),
3279+
"env_keys": env_keys,
3280+
}
3281+
3282+
3283+
def _echo_claude_unwrap_route_cleanup(result: dict[str, Any], port: int) -> bool:
3284+
kind = str(result.get("kind") or "")
3285+
env_keys = [str(key) for key in result.get("env_keys", []) if isinstance(key, str)]
3286+
clean = True
3287+
if kind == "local":
3288+
status = str(result.get("status") or "failed")
3289+
_echo_unwrap_proxy_stop_status(status, port)
3290+
clean = status in {"stopped", "not_running"}
3291+
elif kind == "persistent_stopped":
3292+
manifest = result["manifest"]
3293+
click.echo(
3294+
f" Stopped Claude-owned persistent deployment '{manifest.profile}' on port {port}."
3295+
)
3296+
elif kind == "persistent_residue":
3297+
manifest = result["manifest"]
3298+
click.echo(
3299+
" Warning: same-port persistent deployment "
3300+
f"'{manifest.profile}' still owns port {port}; left it running because it is not "
3301+
"clearly Claude-targeted."
3302+
)
3303+
click.echo(f" To stop it, run `headroom install stop --profile {manifest.profile}`.")
3304+
click.echo(
3305+
f" To remove it completely, run `headroom install remove --profile {manifest.profile}`."
3306+
)
3307+
clean = False
3308+
elif kind == "persistent_failed":
3309+
manifest = result["manifest"]
3310+
click.echo(
3311+
" Warning: failed to stop Claude-owned persistent deployment "
3312+
f"'{manifest.profile}' on port {port}: {result.get('error')}"
3313+
)
3314+
click.echo(f" Retry with `headroom install stop --profile {manifest.profile}`.")
3315+
clean = False
3316+
if env_keys:
3317+
click.echo(
3318+
" Warning: current shell still exports "
3319+
+ ", ".join(env_keys)
3320+
+ f" for port {port}; restart Claude and your shell or unset those variables."
3321+
)
3322+
clean = False
3323+
return clean
3324+
3325+
31893326
def _echo_unwrap_proxy_stop_status(status: str, port: int) -> None:
31903327
"""Print a human-readable proxy stop result for unwrap commands."""
31913328

@@ -4778,9 +4915,18 @@ def unwrap_claude(
47784915
)
47794916

47804917
click.echo()
4781-
click.echo("✓ Claude is no longer durably wrapped by Headroom.")
4782-
if not no_stop_proxy:
4783-
_echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port)
4918+
clean_unwrap = True
4919+
if no_stop_proxy:
4920+
click.echo(" Kept proxy stop disabled (--no-stop-proxy).")
4921+
clean_unwrap = False
4922+
else:
4923+
clean_unwrap = _echo_claude_unwrap_route_cleanup(_unwrap_claude_route_cleanup(port), port)
4924+
if clean_unwrap:
4925+
click.echo("✓ Claude is no longer durably wrapped by Headroom.")
4926+
else:
4927+
click.echo(
4928+
" Claude local wrap settings were removed, but effective routing residue remains."
4929+
)
47844930
click.echo()
47854931

47864932

tests/test_cli/test_unwrap_claude.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ def runner() -> CliRunner:
1616
return CliRunner()
1717

1818

19+
@pytest.fixture(autouse=True)
20+
def _no_persistent_manifest(monkeypatch: pytest.MonkeyPatch) -> None:
21+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda _port: None)
22+
23+
1924
def test_remove_claude_rtk_hooks_preserves_unrelated_hooks(tmp_path: Path) -> None:
2025
settings = tmp_path / "settings.json"
2126
settings.write_text(
@@ -246,6 +251,106 @@ def restore_base_url(previous: str | None, **kwargs: object) -> None:
246251
]
247252

248253

254+
def test_unwrap_claude_stops_claude_owned_persistent_deployment(
255+
runner: CliRunner,
256+
monkeypatch: pytest.MonkeyPatch,
257+
) -> None:
258+
class Manifest:
259+
profile = "unwrap-2340"
260+
targets = ["claude"]
261+
tool_envs = {"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}
262+
mutations: list[object] = []
263+
supervisor_kind = "service"
264+
265+
stopped: list[str] = []
266+
deactivated: list[str] = []
267+
268+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: Manifest())
269+
monkeypatch.setattr(
270+
"headroom.cli.install._deactivate_deployment_mutations",
271+
lambda manifest: deactivated.append(manifest.profile),
272+
)
273+
monkeypatch.setattr(
274+
"headroom.cli.install._stop_deployment",
275+
lambda manifest: stopped.append(manifest.profile),
276+
)
277+
278+
with (
279+
patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_local,
280+
):
281+
result = runner.invoke(
282+
main,
283+
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
284+
)
285+
286+
assert result.exit_code == 0, result.output
287+
stop_local.assert_not_called()
288+
assert deactivated == ["unwrap-2340"]
289+
assert stopped == ["unwrap-2340"]
290+
assert "Stopped Claude-owned persistent deployment 'unwrap-2340' on port 8787." in result.output
291+
assert "Claude is no longer durably wrapped by Headroom." in result.output
292+
293+
294+
def test_unwrap_claude_reports_ambiguous_same_port_persistent_deployment(
295+
runner: CliRunner,
296+
monkeypatch: pytest.MonkeyPatch,
297+
) -> None:
298+
class Manifest:
299+
profile = "shared-proxy"
300+
targets = ["codex"]
301+
tool_envs = {"codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787"}}
302+
mutations: list[object] = []
303+
supervisor_kind = "service"
304+
305+
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: Manifest())
306+
307+
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_local:
308+
result = runner.invoke(
309+
main,
310+
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
311+
)
312+
313+
assert result.exit_code == 0, result.output
314+
stop_local.assert_not_called()
315+
assert "same-port persistent deployment 'shared-proxy' still owns port 8787" in result.output
316+
assert "headroom install stop --profile shared-proxy" in result.output
317+
assert "Claude is no longer durably wrapped by Headroom." not in result.output
318+
319+
320+
def test_unwrap_claude_warns_about_same_port_inherited_env(
321+
runner: CliRunner,
322+
monkeypatch: pytest.MonkeyPatch,
323+
) -> None:
324+
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8787")
325+
326+
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
327+
result = runner.invoke(
328+
main,
329+
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
330+
)
331+
332+
assert result.exit_code == 0, result.output
333+
assert "current shell still exports ANTHROPIC_BASE_URL for port 8787" in result.output
334+
assert "Claude is no longer durably wrapped by Headroom." not in result.output
335+
336+
337+
def test_unwrap_claude_ignores_malformed_inherited_env_port(
338+
runner: CliRunner,
339+
monkeypatch: pytest.MonkeyPatch,
340+
) -> None:
341+
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:notaport")
342+
343+
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
344+
result = runner.invoke(
345+
main,
346+
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
347+
)
348+
349+
assert result.exit_code == 0, result.output
350+
assert "current shell still exports ANTHROPIC_BASE_URL" not in result.output
351+
assert "Claude is no longer durably wrapped by Headroom." in result.output
352+
353+
249354
def test_remove_claude_rtk_hooks_removes_init_hooks_and_env(tmp_path: Path) -> None:
250355
settings = tmp_path / "settings.json"
251356
settings.write_text(

0 commit comments

Comments
 (0)