Skip to content

fix(wrap): never reuse a non-Headroom listener on the proxy port - #3799

Open
trs-80 wants to merge 1 commit into
headroomlabs-ai:mainfrom
trs-80:fix/wrap-foreign-listener
Open

trs-80 wants to merge 1 commit into
headroomlabs-ai:mainfrom
trs-80:fix/wrap-foreign-listener

Conversation

@trs-80

@trs-80 trs-80 commented Sep 25, 2026

Copy link
Copy Markdown
Contributor

Description

headroom wrap decided a proxy was running with _check_proxy, which is a bare TCP connect. Any service listening on the wrap port passed that check. When something other than Headroom held the port (seen in practice: a caveman gateway on 8787), wrap would:

  • reuse it and route the tool at it
  • wait on it as a persistent deployment
  • treat it as a live session in the dead-marker self-heal

A listener now counts as Headroom only if /health or /config answers like one. Otherwise wrap uses the existing port search.

Split out of #3366. These fixes apply to every harness and don't depend on the wrap-target registry.

Closes #

Type of Change

  • Bug fix (non-breaking change that fixes an issue)

Changes Made

  • headroom/cli/wrap.py:
    • _foreign_listener(port): TCP accepts, but neither /health nor /config identifies Headroom.
    • _ensure_proxy_unlocked: a foreign listener is never reused. The port search takes over, and a persistent manifest on a foreign-held port falls through to a fresh proxy instead of raising "not healthy".
    • _check_and_clear_dead_wrap_marker: a foreign listener on a dead wrapper's port no longer counts as a live session (the marker is kept while the writer PID is alive).
    • _launch_tool: on port fallback the launch banner is rewritten too, so it shows the URL the child actually got (the env already was).
    • wrap claude: the marker/owner claim records actual_port instead of the requested port.
    • _ensure_claude_wrap_selfheal_hook: a marker-bearing hook whose command drifted (hand edit, moved binary) is rewritten in place instead of skipped.
  • tests/test_cli/test_wrap_claude_vertex_proxy_env.py: runs in an isolated cwd so it can't write a selfheal hook into the repo's .claude/settings.local.json. Also gitignored .claude/.headroom_wrap_*.json.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ pytest tests/test_cli/
792 passed, 1 skipped
$ ruff check headroom/cli/wrap.py tests/test_cli/ && ruff format --check headroom/cli/wrap.py tests/test_cli/
All checks passed!
$ mypy headroom/cli/wrap.py
Success: no issues found in 1 source file

New: tests/test_cli/test_wrap_foreign_listener.py (9 tests), test_wrap_selfheal_hook_repairs_stale_command. Updated: test_wrap_kimi.py::test_non_kimi_fallback_display_follows_actual_port (asserted the old stale banner), plus two test_wrap_persistent.py tests that now identify the listener as Headroom via /health.

Real Behavior Proof

  • Environment: macOS (darwin 25.6.0), Python 3.13, this branch vs main at 3aa5012
  • Exact command / steps: python3 -m http.server 8899 as a foreign listener (its /health returns 404); a stub goose on PATH that prints OPENAI_BASE_URL and curls $ANTHROPIC_BASE_URL/health; then headroom wrap goose --port 8899
  • Observed result: main hands the tool the squatter; this branch starts a real proxy on the next port.
    • main: Proxy already running on port 8899 → child gets http://127.0.0.1:8899/v1, and its /health → 404 (the tool is talking to the squatter)
    • this branch: Port 8899 is in use by a non-Headroom service; selecting another port... → proxy starts on 8900, banner and child both show http://127.0.0.1:8900/v1, /health → 200
  • Not tested: Windows; a live persistent (headroom install) deployment whose port was taken over (covered by test_persistent_manifest_on_foreign_port_falls_through)

Runtime Rollout Safety

  • Rollout-managed feature(s): none
  • Minimum rollout channel: n/a
  • Stable/default behavior changed: only when a non-Headroom service holds the wrap port. Wrap used to hand the tool that service; now it starts a Headroom proxy on the next free port. A real Headroom proxy is identified by /health and reused exactly as before.
  • Kill switch / disable path: none needed; pass --port to choose a free port explicitly
  • Unsafe override required: no
  • Qualification impact: none
  • Rollback path: revert this PR; no persisted state or format changes

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • I did not edit CHANGELOG.md — it is generated by release-please from my Conventional Commit PR title (a CI guard enforces this)

Additional Notes

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Sep 25, 2026
`_check_proxy` is a bare TCP connect, so any service squatting the port
(observed: a caveman gateway on 8787) passed as a live Headroom proxy:
wrap reused it, persistent-deployment recovery waited on it, and the
dead-marker self-heal treated it as a live session. A listener is now
Headroom only if /health or /config answers like one; otherwise wrap
falls through to the port search.

Also in the fallback path:
- the launch banner shows the port the child actually receives
- `wrap claude` stamps the marker/owner claim with the actual port
- a selfheal SessionStart hook whose command drifted is rewritten in
  place instead of being left pointing at a stale binary
- the vertex proxy-env test runs in an isolated cwd so it cannot write
  a selfheal hook into the repo's .claude/settings.local.json

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 3.12500% with 31 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/cli/wrap.py 3.12% 31 Missing ⚠️

📢 Thoughts on this report? Let us know!

@trs-80
trs-80 force-pushed the fix/wrap-foreign-listener branch from 7ed3159 to 6f21115 Compare September 25, 2026 22:02

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

status: ready for review Pull request body is complete and the author marked it ready for human review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants