fix(wrap): stop same-port persistent routing during claude unwrap (#2340) (#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 #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 `#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>
This commit is contained in:
Rod Boev 2026-07-17 19:08:40 -04:00 committed by GitHub
parent b75999017f
commit cf5fa644b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 254 additions and 3 deletions

View file

@ -3186,6 +3186,143 @@ def _stop_local_proxy_for_unwrap(port: int) -> str:
return "stopped" if _kill_proxy_by_pid(pid, port) else "failed"
def _manifest_targets_claude(manifest: Any) -> bool:
targets = getattr(manifest, "targets", None)
if isinstance(targets, list) and any(
str(target).strip().lower() == "claude" for target in targets
):
return True
tool_envs = getattr(manifest, "tool_envs", None)
if isinstance(tool_envs, dict) and any(
str(name).strip().lower() == "claude" for name in tool_envs
):
return True
mutations = getattr(manifest, "mutations", None)
if isinstance(mutations, list):
for mutation in mutations:
if str(getattr(mutation, "target", "")).strip().lower() == "claude":
return True
return False
def _can_unwrap_stop_persistent_manifest(manifest: Any) -> bool:
if not _manifest_targets_claude(manifest):
return False
supervisor_kind = str(getattr(manifest, "supervisor_kind", "")).strip().lower()
return supervisor_kind in {"", "none", "service"}
def _same_port_claude_env_keys(port: int) -> list[str]:
matches: list[str] = []
for key in (
"ANTHROPIC_BASE_URL",
"ANTHROPIC_FOUNDRY_BASE_URL",
"ANTHROPIC_VERTEX_BASE_URL",
):
raw = os.environ.get(key, "").strip()
if not raw:
continue
try:
parsed = urllib.parse.urlparse(raw)
except Exception:
continue
try:
parsed_port = parsed.port
except ValueError:
continue
if parsed_port != port:
continue
host = (parsed.hostname or "").strip().lower()
if host not in {"127.0.0.1", "localhost", "::1"}:
continue
matches.append(key)
return matches
def _stop_persistent_manifest_for_claude_unwrap(manifest: Any) -> str | None:
from headroom.cli.install import _deactivate_deployment_mutations, _stop_deployment
try:
_deactivate_deployment_mutations(manifest)
_stop_deployment(manifest)
return None
except Exception as exc:
return str(exc)
def _unwrap_claude_route_cleanup(port: int) -> dict[str, Any]:
manifest = _find_persistent_manifest(port)
env_keys = _same_port_claude_env_keys(port)
if manifest is not None:
if _can_unwrap_stop_persistent_manifest(manifest):
error = _stop_persistent_manifest_for_claude_unwrap(manifest)
if error is None:
return {
"kind": "persistent_stopped",
"manifest": manifest,
"env_keys": env_keys,
}
return {
"kind": "persistent_failed",
"manifest": manifest,
"env_keys": env_keys,
"error": error,
}
return {
"kind": "persistent_residue",
"manifest": manifest,
"env_keys": env_keys,
}
return {
"kind": "local",
"status": _stop_local_proxy_for_unwrap(port),
"env_keys": env_keys,
}
def _echo_claude_unwrap_route_cleanup(result: dict[str, Any], port: int) -> bool:
kind = str(result.get("kind") or "")
env_keys = [str(key) for key in result.get("env_keys", []) if isinstance(key, str)]
clean = True
if kind == "local":
status = str(result.get("status") or "failed")
_echo_unwrap_proxy_stop_status(status, port)
clean = status in {"stopped", "not_running"}
elif kind == "persistent_stopped":
manifest = result["manifest"]
click.echo(
f" Stopped Claude-owned persistent deployment '{manifest.profile}' on port {port}."
)
elif kind == "persistent_residue":
manifest = result["manifest"]
click.echo(
" Warning: same-port persistent deployment "
f"'{manifest.profile}' still owns port {port}; left it running because it is not "
"clearly Claude-targeted."
)
click.echo(f" To stop it, run `headroom install stop --profile {manifest.profile}`.")
click.echo(
f" To remove it completely, run `headroom install remove --profile {manifest.profile}`."
)
clean = False
elif kind == "persistent_failed":
manifest = result["manifest"]
click.echo(
" Warning: failed to stop Claude-owned persistent deployment "
f"'{manifest.profile}' on port {port}: {result.get('error')}"
)
click.echo(f" Retry with `headroom install stop --profile {manifest.profile}`.")
clean = False
if env_keys:
click.echo(
" Warning: current shell still exports "
+ ", ".join(env_keys)
+ f" for port {port}; restart Claude and your shell or unset those variables."
)
clean = False
return clean
def _echo_unwrap_proxy_stop_status(status: str, port: int) -> None:
"""Print a human-readable proxy stop result for unwrap commands."""
@ -4778,9 +4915,18 @@ def unwrap_claude(
)
click.echo()
click.echo("✓ Claude is no longer durably wrapped by Headroom.")
if not no_stop_proxy:
_echo_unwrap_proxy_stop_status(_stop_local_proxy_for_unwrap(port), port)
clean_unwrap = True
if no_stop_proxy:
click.echo(" Kept proxy stop disabled (--no-stop-proxy).")
clean_unwrap = False
else:
clean_unwrap = _echo_claude_unwrap_route_cleanup(_unwrap_claude_route_cleanup(port), port)
if clean_unwrap:
click.echo("✓ Claude is no longer durably wrapped by Headroom.")
else:
click.echo(
" Claude local wrap settings were removed, but effective routing residue remains."
)
click.echo()

View file

@ -16,6 +16,11 @@ def runner() -> CliRunner:
return CliRunner()
@pytest.fixture(autouse=True)
def _no_persistent_manifest(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda _port: None)
def test_remove_claude_rtk_hooks_preserves_unrelated_hooks(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(
@ -246,6 +251,106 @@ def test_unwrap_claude_restores_all_base_url_modes(runner: CliRunner) -> None:
]
def test_unwrap_claude_stops_claude_owned_persistent_deployment(
runner: CliRunner,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Manifest:
profile = "unwrap-2340"
targets = ["claude"]
tool_envs = {"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}}
mutations: list[object] = []
supervisor_kind = "service"
stopped: list[str] = []
deactivated: list[str] = []
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: Manifest())
monkeypatch.setattr(
"headroom.cli.install._deactivate_deployment_mutations",
lambda manifest: deactivated.append(manifest.profile),
)
monkeypatch.setattr(
"headroom.cli.install._stop_deployment",
lambda manifest: stopped.append(manifest.profile),
)
with (
patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_local,
):
result = runner.invoke(
main,
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
)
assert result.exit_code == 0, result.output
stop_local.assert_not_called()
assert deactivated == ["unwrap-2340"]
assert stopped == ["unwrap-2340"]
assert "Stopped Claude-owned persistent deployment 'unwrap-2340' on port 8787." in result.output
assert "Claude is no longer durably wrapped by Headroom." in result.output
def test_unwrap_claude_reports_ambiguous_same_port_persistent_deployment(
runner: CliRunner,
monkeypatch: pytest.MonkeyPatch,
) -> None:
class Manifest:
profile = "shared-proxy"
targets = ["codex"]
tool_envs = {"codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787"}}
mutations: list[object] = []
supervisor_kind = "service"
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: Manifest())
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap") as stop_local:
result = runner.invoke(
main,
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
)
assert result.exit_code == 0, result.output
stop_local.assert_not_called()
assert "same-port persistent deployment 'shared-proxy' still owns port 8787" in result.output
assert "headroom install stop --profile shared-proxy" in result.output
assert "Claude is no longer durably wrapped by Headroom." not in result.output
def test_unwrap_claude_warns_about_same_port_inherited_env(
runner: CliRunner,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:8787")
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(
main,
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
)
assert result.exit_code == 0, result.output
assert "current shell still exports ANTHROPIC_BASE_URL for port 8787" in result.output
assert "Claude is no longer durably wrapped by Headroom." not in result.output
def test_unwrap_claude_ignores_malformed_inherited_env_port(
runner: CliRunner,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("ANTHROPIC_BASE_URL", "http://127.0.0.1:notaport")
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
result = runner.invoke(
main,
["unwrap", "claude", "--keep-mcp", "--keep-rtk", "--port", "8787"],
)
assert result.exit_code == 0, result.output
assert "current shell still exports ANTHROPIC_BASE_URL" not in result.output
assert "Claude is no longer durably wrapped by Headroom." in result.output
def test_remove_claude_rtk_hooks_removes_init_hooks_and_env(tmp_path: Path) -> None:
settings = tmp_path / "settings.json"
settings.write_text(