headroom/e2e/wrap/run.py
Tejas Chopra e0ce4b1d48
fix: remove rtk and lean-ctx CLI context tools (#2677)
## Description

Removes both third-party CLI context tools — **rtk** and **lean-ctx** —
and with them the context-tool selector itself. Headroom no longer
downloads, installs or configures either one, and there is no
replacement.

The previous pass (#2344) gated only three entry points inside
`headroom/cli/wrap.py`. That left the feature reachable in practice:

| Gap | Effect |
|---|---|
| `scripts/install.sh:1544`, `install.ps1:1681` | Ran `rtk init --global
--auto-patch` from bash/PowerShell, **bypassing the Python gate
entirely** — `curl \| sh` still wrote a Claude Code `PreToolUse` hook
regardless of `HEADROOM_RTK` |
| `wrap.py` `_setup_context_tool_for_agent` | **`wrap openhands` was
broken by default**: `rtk_required=True` met a gate returning `None` →
`SystemExit(1)`. Invisible because all 8 openhands tests patched
`_ensure_rtk_binary` to a fake path |
| `proxy/helpers.py`, `subscription/tracker.py` | Proxy shelled out to
`rtk gain` from `/stats`, the dashboard and `headroom perf`; the tracker
polled it per contribution (`_RTK_WIRING_DEFAULT = "enabled"`) |
| No cleanup path | Nothing removed artifacts an earlier default had
installed, so a machine that once ran the old default kept rtk in the
loop forever (#1669, #1955) |

Also worth noting: the rtk binary download had **no SHA or signature
verification** — only `rtk --version` as a smoke test.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [x] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [x] Code refactoring (no functional changes)

## Changes Made

**Removed** — `headroom/rtk/` and `headroom/lean_ctx/` packages,
`headroom/cli/wrap_rtk_metrics.py`, `_selected_context_tool` /
`_setup_context_tool_for_agent` / `_VALID_CONTEXT_TOOLS`, the `--rtk` /
`--no-rtk` / `--no-project-rtk` / `--keep-rtk` flags across all 18 wrap
subcommands, `HEADROOM_RTK*`, the proxy-side `rtk gain` polling, the
dashboard CLI-filtering panel (rows + all 8 `cliFiltering*` Alpine
getters), `paths.rtk_path()` / `lean_ctx_path()`, the SDK path helpers,
`benchmarks/rtk_loop_learn_eval.py`, and the `headroom/rtk/**` CI path
filters.

**Fails loudly, not silently** — `--context-tool` / `--no-context-tool`
/ `HEADROOM_CONTEXT_TOOL` are kept solely to error out. They live in
shell profiles, aliases and CI jobs, and accepting them as a no-op would
read as Headroom having quietly stopped working. The installers reject
them too, which matters more than it looks: their arg parsers forward
the first unknown flag **and everything after it** to the wrapped tool,
so a leftover `--no-rtk` would have silently swallowed a following
`--port` and then been ignored downstream.

**New `headroom/context_tool_cleanup.py`** — deleting the code cannot
help a machine that already ran the old default, since the hooks,
binaries and injected guidance are durable on disk.
`purge_context_tool_artifacts()` runs once per `wrap`/`unwrap` and
removes the registered hook entries, the generated hook scripts, the
Headroom-managed `~/.local/bin` symlinks, the vendored
`~/.headroom/bin/{rtk,lean-ctx}` binaries, the `lean-ctx` MCP server
entry and the marker-fenced instruction blocks. Deliberately
conservative: idempotent, **skips** a malformed config rather than
overwriting it, and only unlinks a symlink resolving inside Headroom's
own bin dir so a user's own build is untouched. It reports on
**stderr**, because `wrap/unwrap openclaw --prepare-only` emit
machine-readable JSON on stdout as their entire contract. Skipped for
`wrap selfheal` (runs from a SessionStart hook; must not race Claude
Code's writer for `~/.claude.json`) and for `--help`, which must stay
read-only.

**Client-config hardening** (discovered while investigating a "corrupted
Serena settings file" report) — `wrap.py` reset a settings file to `{}`
when an existing file would not parse, then wrote that back. One
hand-edited typo or a transient `EACCES`/`EINTR` on a valid file
destroyed the user's `permissions`, `env` and `hooks`, on **every
`headroom wrap claude`**. It now refuses to write. Separately,
`fsutil.write_text` is now atomic (temp file + `fsync` + `os.replace`),
fixing all 14 non-atomic client-config writes at once; it follows
symlinks rather than replacing them (dotfile managers) and preserves an
existing file's mode.

**Deliberately kept** — `rtk` stays in the wrapper-peel list in
`transforms/content_router.py`. It sits beside `sudo`/`env`/`timeout` as
shell-command grammar, so `rtk cat f` is still classified as a file read
for anyone running their own rtk install, which the purge intentionally
leaves alone.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ ruff check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates
All checks passed!

$ ruff format --check headroom/ tests/ e2e/ --exclude headroom/dashboard/templates
1255 files already formatted

$ mypy headroom/
Success: no issues found in 508 source files

$ pytest tests/test_context_tool_cleanup.py -q
11 passed

$ pytest tests/test_fsutil.py -q
12 passed

$ pytest tests/test_cli/test_wrap_codex.py -q            # 89 tests
89 passed in 431.68s
$ pytest tests/test_cli/test_wrap_opencode.py -q
39 passed in 257.46s
$ pytest tests/test_cli/test_wrap_helpers.py -q
45 passed
$ pytest tests/test_paths.py -q
75 passed
$ pytest tests/test_cli/test_unwrap_claude.py -q
14 passed
$ pytest tests/test_proxy_savings_history.py -q
39 passed
$ pytest tests/test_cli/test_wrap_copilot.py -q
27 passed
$ pytest tests/test_cli/test_wrap_zcode.py -q
20 passed
$ pytest tests/test_subscription_tracker.py -q
9 passed
$ pytest tests/test_proxy_dashboard_stats_cache.py -q
5 passed, 1 skipped
```

Repo-wide grep for 14 removed symbols (`headroom.rtk`,
`headroom.lean_ctx`, `_ensure_rtk_binary`, `_selected_context_tool`,
`_get_context_tool_stats`, `rtk_path`, `lean_ctx_path`,
`wrap_rtk_metrics`, `HEADROOM_RTK`, `cli_tokens_avoided`,
`tokens_saved_rtk`, …) across `*.py`, `*.ts`, `*.sh`, `*.ps1`, `*.yml`,
`*.html`: **zero hits**.

Notable test changes: `test_wrap_openhands.py` no longer patches
`_ensure_rtk_binary` and asserts `wrap openhands --prepare-only` exits 0
unpatched — the regression that was previously masked.
`test_wrap_continue.py` and `test_wrap_hintfile_agents.py` were removed
(every test drove RTK instruction injection). A new
`test_subscription_tracker.py::test_load_state_written_before_cli_context_tools_were_removed`
proves a pre-removal `subscription_state.json` still loads.

## Real Behavior Proof

- **Environment:** macOS 15.4 (darwin 25.4.0), Python 3.12.6, Headroom @
this branch, real `~/.headroom` and `~/.claude` on the dev machine.
- **Exact command / steps and observed result:**

```text
# 1. Retired flag fails loudly instead of silently no-op'ing
$ headroom wrap codex --prepare-only --context-tool rtk
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: they
rewrote shell commands through a third-party binary Headroom no longer manages.
Drop --context-tool / --no-context-tool and unset HEADROOM_CONTEXT_TOOL;
`headroom wrap` uninstalls what they left behind on first run.

$ HEADROOM_CONTEXT_TOOL=lean-ctx headroom wrap codex --prepare-only
Error: CLI context tools (rtk, lean-ctx) have been removed from Headroom: ...

# 2. install.sh rejects the retired flags (extracted parse_wrap_args harness)
['--no-rtk', '--port', '9999']   rc=1  ERROR: CLI context tools ... Drop --no-rtk
['--context-tool=rtk']           rc=1  ERROR: CLI context tools ... Drop --context-tool
$ bash -n scripts/install.sh   # syntax OK

# 3. Purge ran against the real machine, which had all the orphaned artifacts
$ python -c "from headroom.context_tool_cleanup import purge_context_tool_artifacts; ..."
  removed ~/.headroom/bin/lean-ctx        (51 MB)
  removed ~/.headroom/bin/rtk             (7.7 MB)
  removed ~/.local/bin/rtk                (symlink into ~/.headroom/bin)
  removed ~/.claude/hooks/rtk-rewrite.sh
  removed 8 lean-ctx-* hook scripts
# ~/.claude.json afterwards: 90 top-level keys, 19 projects, mcpServers unchanged
# → ~59 MB reclaimed, no unrelated key touched

# 4. stdout stays machine-readable while the purge reports (planted a fake artifact)
$ headroom wrap openclaw --prepare-only --gateway-provider-id codex >out 2>err
$ cat out
{"enabled":true,"config":{"proxyPort":8787,...}}     # parses as JSON
$ cat err
Retired CLI context tool cleanup: removed /Users/tcms/.headroom/bin/rtk

# 5. --help is inert (planted artifact survives), a real run purges
$ headroom wrap codex --help   → artifact survived: CORRECT
$ headroom wrap openclaw --prepare-only → purged: CORRECT

# 6. MCP purge dry-run against a copy of the real 82 KB ~/.claude.json
top-level keys 90 -> 90;  projects 19 -> 19;  LOST keys: none
all content outside mcpServers byte-identical: True
```

Dashboard rendered via the Playwright test after the panel removal:
"Token Savings" shows only `Proxy 0 (0.0%)` / `Of total wire: 36.86%`,
and "Token Usage" reads Before Compression → Proxy Removed → After
Compression with no "Filtered (this session)" row. Nothing below the
removed panel broke.

- **Not tested:** Windows and Linux (macOS only) — `install.ps1` is
verified by brace-balance and inspection, not executed, since no `pwsh`
is available locally. The wrap e2e suite (`e2e/wrap/run.py`) was updated
but not run; it needs the Docker e2e image. `serena project index`
interaction is exercised in the stacked base PR.

## 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
- [x] 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 did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

**Stacked on #2676** (`tejas/serena-config-bootstrap`) — please merge
that first; this PR's base should then be retargeted to `main`, or it
will read as containing that fix too.

**Breaking-change migration for users:**
- Drop `--rtk`, `--no-rtk`, `--no-project-rtk`, `--keep-rtk`,
`--context-tool`, `--no-context-tool` from any alias, script or CI job,
and unset `HEADROOM_RTK*` / `HEADROOM_CONTEXT_TOOL`. They now error
rather than being ignored, so the failure is immediate and
self-explaining.
- Previously-installed artifacts are purged automatically on the next
`wrap`/`unwrap`; no manual cleanup needed.
- `headroom perf --json` no longer carries a `cli_filtering` key, and
`/stats` no longer returns a `context_tool` section.

**Docs:** `docs/rtk-architecture.md` deleted; RTK/lean-ctx removed from
`README.md`,
`docs/content/docs/{configuration,opencode,grok-build,docker-install,filesystem-contract}.mdx`,
`docs/observability.md` and the matching `wiki/` pages.
`REALIGNMENT/09-phase-G-rtk-observability.md` is marked SUPERSEDED
rather than deleted, to keep the planning record.

**Follow-ups not in scope:** `_emit_wrap_interrupted` was deleted as
dead code — its only caller was the `except KeyboardInterrupt` guarding
the binary download, so with no download there is nothing slow left to
interrupt.
2026-07-30 22:59:41 -07:00

951 lines
34 KiB
Python

from __future__ import annotations
import json
import os
import shutil
import signal
import stat
import subprocess
import sys
import tempfile
import textwrap
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import quote
import httpx
REPO_ROOT = Path("/workspace")
PLUGIN_DIR = REPO_ROOT / "plugins" / "openclaw"
SDK_DIR = REPO_ROOT / "sdk" / "typescript"
PROXY_PORT = 28887
CODEX_PORT = 28888
AIDER_PORT = 28889
CURSOR_PORT = 28890
OPENCLAW_PROXY_PORT = 28891
# Phase G PR-G1: new wrap subcommands. Smoke-tested via --prepare-only since
# their CLIs may not exist on the e2e image and the wrap commands without
# --prepare-only block on the proxy. The wiring is otherwise covered by the
# unit tests in tests/test_cli/test_wrap_{cline,continue,goose,openhands}.py.
CLINE_PORT = 28892
CONTINUE_PORT = 28893
GOOSE_PORT = 28894
OPENHANDS_PORT = 28895
OPENCODE_PORT = 28896
def log(message: str) -> None:
print(f"[wrap-e2e] {message}", flush=True)
def run(
cmd: list[str],
*,
env: dict[str, str] | None = None,
cwd: Path | None = None,
timeout: int = 180,
) -> subprocess.CompletedProcess[str]:
log(f"$ {' '.join(cmd)}")
result = subprocess.run(
cmd,
env=env,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
if result.stdout.strip():
print(result.stdout.rstrip(), flush=True)
if result.stderr.strip():
print(result.stderr.rstrip(), file=sys.stderr, flush=True)
if result.returncode != 0:
raise RuntimeError(f"Command failed with exit code {result.returncode}: {' '.join(cmd)}")
return result
def assert_true(condition: bool, message: str) -> None:
if not condition:
raise AssertionError(message)
def write_executable(path: Path, content: str) -> None:
path.write_text(content, encoding="utf-8")
current_mode = path.stat().st_mode
path.chmod(current_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
class MockOpenAIServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def __init__(self, server_address: tuple[str, int]) -> None:
super().__init__(server_address, MockOpenAIHandler)
self.requests: list[dict[str, Any]] = []
class MockOpenAIHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format: str, *args: object) -> None: # noqa: A003
return
def _write_json(self, status_code: int, payload: dict[str, Any]) -> None:
body = json.dumps(payload).encode("utf-8")
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _record(self, body: dict[str, Any] | None = None) -> None:
server = self.server
assert isinstance(server, MockOpenAIServer)
server.requests.append(
{
"method": self.command,
"path": self.path,
"authorization": self.headers.get("Authorization"),
"body": body,
}
)
def do_GET(self) -> None: # noqa: N802
self._record()
if self.path == "/v1/models":
self._write_json(
200,
{
"object": "list",
"data": [
{
"id": "gpt-4o-mini",
"object": "model",
"owned_by": "openai",
}
],
},
)
return
self._write_json(404, {"error": {"message": "not found"}})
def do_POST(self) -> None: # noqa: N802
length = int(self.headers.get("Content-Length", "0"))
raw_body = self.rfile.read(length) if length else b""
payload = json.loads(raw_body.decode("utf-8") or "{}")
self._record(body=payload)
if self.path == "/v1/chat/completions":
self._write_json(
200,
{
"id": "chatcmpl-e2e",
"object": "chat.completion",
"created": 0,
"model": payload.get("model", "gpt-4o-mini"),
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "mock completion from upstream",
},
}
],
"usage": {
"prompt_tokens": 12,
"completion_tokens": 5,
"total_tokens": 17,
},
},
)
return
self._write_json(404, {"error": {"message": "not found"}})
def wait_for_http(url: str, *, timeout: int = 30) -> httpx.Response:
deadline = time.time() + timeout
last_error: Exception | None = None
while time.time() < deadline:
try:
response = httpx.get(url, timeout=2.0)
if response.status_code < 500:
return response
except Exception as exc: # pragma: no cover - best effort retry surface
last_error = exc
time.sleep(0.5)
raise RuntimeError(f"Timed out waiting for {url}: {last_error}")
def wait_for_output(proc: subprocess.Popen[str], text: str, *, timeout: int = 30) -> str:
deadline = time.time() + timeout
chunks: list[str] = []
while time.time() < deadline:
if proc.stdout is None:
break
line = proc.stdout.readline()
if line:
chunks.append(line)
if text in "".join(chunks):
return "".join(chunks)
elif proc.poll() is not None:
break
output = "".join(chunks)
raise RuntimeError(f"Timed out waiting for process output '{text}'. Output so far:\n{output}")
def read_jsonl(path: Path) -> list[dict[str, Any]]:
if not path.exists():
return []
return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line]
def create_shims(shim_dir: Path) -> None:
generic_shim = textwrap.dedent(
"""\
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import urllib.request
from pathlib import Path
tool = Path(sys.argv[0]).name
log_dir = Path(os.environ["HEADROOM_E2E_LOG_DIR"])
log_dir.mkdir(parents=True, exist_ok=True)
record = {
"tool": tool,
"argv": sys.argv[1:],
"cwd": os.getcwd(),
"env": {
key: os.environ.get(key)
for key in (
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"ANTHROPIC_BASE_URL",
"CODEX_HOME",
"OPENCODE_CONFIG_CONTENT",
)
if os.environ.get(key) is not None
},
}
probes = []
def fetch(url: str, *, headers: dict[str, str] | None = None) -> None:
request = urllib.request.Request(url, headers=headers or {})
with urllib.request.urlopen(request, timeout=10) as response:
probes.append({"url": url, "status": response.status})
openai_base = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
if openai_base:
fetch(
f"{openai_base.rstrip('/')}/models",
headers={"Authorization": "Bearer test-key"},
)
anthropic_base = os.environ.get("ANTHROPIC_BASE_URL")
if anthropic_base:
fetch(f"{anthropic_base.rstrip('/')}/health")
record["probes"] = probes
with (log_dir / f"{tool}.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\\n")
print(f"{tool} shim executed")
raise SystemExit(0)
"""
)
codex_shim = textwrap.dedent(
"""\
#!/usr/bin/env python3
from __future__ import annotations
import json
import os
import sys
import urllib.request
from pathlib import Path
tool = Path(sys.argv[0]).name
log_dir = Path(os.environ["HEADROOM_E2E_LOG_DIR"])
log_dir.mkdir(parents=True, exist_ok=True)
record = {
"tool": tool,
"argv": sys.argv[1:],
"cwd": os.getcwd(),
"env": {
key: os.environ.get(key)
for key in (
"OPENAI_BASE_URL",
"OPENAI_API_BASE",
"ANTHROPIC_BASE_URL",
"CODEX_HOME",
"OPENCODE_CONFIG_CONTENT",
)
if os.environ.get(key) is not None
},
}
probes = []
def request_json(
url: str,
*,
payload: dict[str, object] | None = None,
headers: dict[str, str] | None = None,
) -> tuple[int, dict[str, object]]:
data = None if payload is None else json.dumps(payload).encode("utf-8")
request = urllib.request.Request(url, data=data, headers=headers or {})
if payload is not None:
request.add_header("Content-Type", "application/json")
with urllib.request.urlopen(request, timeout=10) as response:
raw = response.read()
body = json.loads(raw.decode("utf-8") or "{}") if raw else {}
return response.status, body
openai_base = os.environ.get("OPENAI_BASE_URL") or os.environ.get("OPENAI_API_BASE")
if openai_base:
auth_headers = {"Authorization": "Bearer test-key"}
models_status, _models_body = request_json(
f"{openai_base.rstrip('/')}/models",
headers=auth_headers,
)
probes.append({"url": f"{openai_base.rstrip('/')}/models", "status": models_status})
model_name = "headroom-wrap-e2e"
chat_status, chat_body = request_json(
f"{openai_base.rstrip('/')}/chat/completions",
payload={
"model": model_name,
"messages": [
{
"role": "user",
"content": "Confirm Headroom received this wrapped Codex message.",
}
],
},
headers=auth_headers,
)
probes.append(
{"url": f"{openai_base.rstrip('/')}/chat/completions", "status": chat_status}
)
record["chat_completion"] = (
chat_body.get("choices", [{}])[0].get("message", {}).get("content")
)
stats_url = openai_base.rstrip("/").removesuffix("/v1") + "/stats"
stats_status, stats_body = request_json(stats_url, headers=auth_headers)
probes.append({"url": stats_url, "status": stats_status})
requests = stats_body.get("requests", {})
by_model = requests.get("by_model", {}) if isinstance(requests, dict) else {}
record["headroom_request_total"] = (
requests.get("total") if isinstance(requests, dict) else None
)
record["headroom_model_count"] = (
by_model.get(model_name) if isinstance(by_model, dict) else None
)
codex_home = os.environ.get("CODEX_HOME")
if codex_home:
session_config = Path(codex_home) / "config.toml"
record["session_config"] = (
session_config.read_text(encoding="utf-8") if session_config.exists() else None
)
record["probes"] = probes
with (log_dir / f"{tool}.jsonl").open("a", encoding="utf-8") as handle:
handle.write(json.dumps(record) + "\\n")
print(f"{tool} shim executed")
raise SystemExit(0)
"""
)
write_executable(shim_dir / "claude", generic_shim)
write_executable(shim_dir / "codex", codex_shim)
write_executable(shim_dir / "aider", generic_shim)
write_executable(shim_dir / "opencode", generic_shim)
def start_mock_server(port: int) -> tuple[MockOpenAIServer, threading.Thread]:
server = MockOpenAIServer(("127.0.0.1", port))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server, thread
def start_proxy(port: int, env: dict[str, str]) -> subprocess.Popen[str]:
log(f"Starting headroom proxy on port {port}")
proc = subprocess.Popen(
["headroom", "proxy", "--host", "127.0.0.1", "--port", str(port)],
env=env,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
wait_for_http(f"http://127.0.0.1:{port}/health", timeout=30)
return proc
def stop_process(proc: subprocess.Popen[str]) -> None:
if proc.poll() is not None:
return
proc.send_signal(signal.SIGINT)
try:
proc.communicate(timeout=10)
except subprocess.TimeoutExpired:
proc.kill()
proc.communicate(timeout=5)
def wait_for_command_success(
cmd: list[str],
*,
env: dict[str, str],
cwd: Path | None = None,
timeout: int = 30,
) -> subprocess.CompletedProcess[str]:
deadline = time.time() + timeout
last_output = ""
while time.time() < deadline:
remaining = deadline - time.time()
per_call_timeout = max(1.0, min(5.0, remaining))
try:
result = subprocess.run(
cmd,
env=env,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=per_call_timeout,
)
except subprocess.TimeoutExpired:
last_output = f"Command timed out after {per_call_timeout:.1f}s"
time.sleep(1)
continue
if result.returncode == 0:
if result.stdout.strip():
print(result.stdout.rstrip(), flush=True)
if result.stderr.strip():
print(result.stderr.rstrip(), file=sys.stderr, flush=True)
return result
last_output = "\n".join(
part for part in (result.stdout.strip(), result.stderr.strip()) if part
)
time.sleep(1)
raise RuntimeError(
f"Timed out waiting for command to succeed: {' '.join(cmd)}\nLast output:\n{last_output}"
)
def start_openclaw_gateway(env: dict[str, str], cwd: Path) -> subprocess.Popen[str]:
log("Starting OpenClaw gateway for e2e verification")
return subprocess.Popen(
["openclaw", "gateway"],
env=env,
cwd=str(cwd),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
def stop_openclaw_gateway(env: dict[str, str], cwd: Path) -> None:
log("Stopping OpenClaw gateway after e2e verification")
run(["openclaw", "gateway", "stop"], env=env, cwd=cwd, timeout=60)
def verify_installs() -> None:
log("Verifying installed packages and binaries")
for tool in ("headroom", "codex", "aider", "openclaw"):
assert_true(shutil.which(tool) is not None, f"Expected '{tool}' on PATH")
run(["headroom", "--help"], timeout=30)
run(["npm", "list", "-g", "--depth=0", "@openai/codex", "openclaw"], timeout=60)
run(["/opt/aider-venv/bin/python", "-m", "pip", "show", "aider-chat"], timeout=60)
def prepare_local_openclaw_plugin(base_env: dict[str, str], tmp_dir: Path) -> Path:
log("Preparing local TypeScript package for OpenClaw plugin build")
sdk_dir = tmp_dir / "sdk-typescript"
plugin_dir = tmp_dir / "openclaw-plugin"
shutil.copytree(SDK_DIR, sdk_dir)
shutil.copytree(PLUGIN_DIR, plugin_dir)
plugin_lock = plugin_dir / "package-lock.json"
if plugin_lock.exists():
plugin_lock.unlink()
run(["npm", "install"], env=base_env, cwd=sdk_dir, timeout=600)
run(["npm", "run", "build"], env=base_env, cwd=sdk_dir, timeout=600)
pack_result = run(["npm", "pack"], env=base_env, cwd=sdk_dir, timeout=600)
tarball_name = pack_result.stdout.strip().splitlines()[-1].strip()
tarball_path = sdk_dir / tarball_name
assert_true(tarball_path.exists(), "Expected npm pack to produce a local SDK tarball")
package_json_path = plugin_dir / "package.json"
package_json = json.loads(package_json_path.read_text(encoding="utf-8"))
package_json["dependencies"]["headroom-ai"] = f"file:{tarball_path.as_posix()}"
package_json_path.write_text(f"{json.dumps(package_json, indent=2)}\n", encoding="utf-8")
return plugin_dir
def verify_proxy_round_trip(base_env: dict[str, str], mock_server: MockOpenAIServer) -> None:
proxy_port = PROXY_PORT
proc = start_proxy(proxy_port, base_env)
try:
health = wait_for_http(f"http://127.0.0.1:{proxy_port}/health")
assert_true(health.status_code == 200, "Proxy health check should return 200")
models = httpx.get(
f"http://127.0.0.1:{proxy_port}/v1/models",
headers={"Authorization": "Bearer test-key"},
timeout=10.0,
)
assert_true(models.status_code == 200, "Proxy should pass through /v1/models")
assert_true(models.json()["data"][0]["id"] == "gpt-4o-mini", "Unexpected models payload")
chat = httpx.post(
f"http://127.0.0.1:{proxy_port}/v1/chat/completions",
headers={"Authorization": "Bearer test-key"},
json={
"model": "gpt-4o-mini",
"messages": [{"role": "user", "content": "Say hello."}],
},
timeout=10.0,
)
assert_true(chat.status_code == 200, "Proxy should pass through chat completions")
assert_true(
chat.json()["choices"][0]["message"]["content"] == "mock completion from upstream",
"Unexpected chat completion payload",
)
assert_true(
any(item["path"] == "/v1/models" for item in mock_server.requests),
"Mock upstream should receive /v1/models",
)
assert_true(
any(item["path"] == "/v1/chat/completions" for item in mock_server.requests),
"Mock upstream should receive /v1/chat/completions",
)
finally:
stop_process(proc)
def verify_codex_wrap(
base_env: dict[str, str], project_dir: Path, log_dir: Path, mock_server: MockOpenAIServer
) -> None:
port = CODEX_PORT
run(
["headroom", "wrap", "codex", "--port", str(port), "--", "--help"],
env=base_env,
cwd=project_dir,
timeout=120,
)
config_path = Path(base_env["HOME"]) / ".codex" / "config.toml"
assert_true(
config_path.exists(),
"Codex wrap should persist its MCP setup in the durable Codex home",
)
durable_config = config_path.read_text(encoding="utf-8")
assert_true(
"[mcp_servers.headroom]" in durable_config,
"Codex wrap should persist the Headroom MCP server",
)
assert_true(
'model_provider = "headroom"' not in durable_config
and "[model_providers.headroom]" not in durable_config
and "openai_base_url" not in durable_config,
"Codex wrap should keep proxy routing out of the durable config",
)
entries = read_jsonl(log_dir / "codex.jsonl")
assert_true(len(entries) > 0, "Codex shim should have been invoked")
env_vars = entries[-1]["env"]
session_home = env_vars.get("CODEX_HOME")
assert_true(
isinstance(session_home, str) and session_home,
"Codex wrap should launch the child with CODEX_HOME",
)
assert_true(
Path(session_home) == config_path.parent,
"Codex wrap should point the child at the durable ~/.codex home",
)
config = entries[-1].get("session_config")
assert_true(
isinstance(config, str) and config,
"Codex wrap should capture the durable config during launch",
)
project_prefix = f"/p/{quote(project_dir.name, safe='')}"
expected_base_url = f"http://127.0.0.1:{port}{project_prefix}/v1"
argv = entries[-1].get("argv")
assert_true(
isinstance(argv, list) and f'openai_base_url="{expected_base_url}"' in argv,
"Codex wrap should pass openai_base_url as a process-local override",
)
assert_true(
'env_key = "OPENAI_API_KEY"' not in config,
"Codex wrap should preserve OAuth and never inject env_key into the session config",
)
assert_true(
env_vars.get("OPENAI_BASE_URL") == expected_base_url,
"Codex wrap should set OPENAI_BASE_URL",
)
assert_true(
entries[-1]["probes"]
== [
{"url": f"{expected_base_url}/models", "status": 200},
{"url": f"{expected_base_url}/chat/completions", "status": 200},
{
"url": f"http://127.0.0.1:{port}{project_prefix}/stats",
"status": 200,
},
],
"Codex shim should prove OPENAI_BASE_URL points at a live proxy and that Headroom logged the wrapped message",
)
assert_true(
entries[-1].get("chat_completion") == "mock completion from upstream",
"Codex wrap should receive the mock upstream completion through Headroom",
)
assert_true(
entries[-1].get("headroom_model_count", 0) >= 1,
"Codex wrap should appear in Headroom request stats",
)
assert_true(
any(
item["path"] == "/v1/chat/completions"
and isinstance(item.get("body"), dict)
and item["body"].get("model") == "headroom-wrap-e2e"
for item in mock_server.requests
),
"Codex wrap should forward the wrapped message upstream through Headroom",
)
def verify_claude_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = PROXY_PORT + 10
run(
["headroom", "wrap", "claude", "--port", str(port), "--", "--help"],
env=base_env,
cwd=project_dir,
timeout=120,
)
entries = read_jsonl(log_dir / "claude.jsonl")
assert_true(len(entries) > 0, "Claude shim should have been invoked")
env_vars = entries[-1]["env"]
assert_true(
env_vars.get("ANTHROPIC_BASE_URL") == f"http://127.0.0.1:{port}",
"Claude wrap should set ANTHROPIC_BASE_URL",
)
assert_true(
entries[-1]["probes"] == [{"url": f"http://127.0.0.1:{port}/health", "status": 200}],
"Claude shim should prove ANTHROPIC_BASE_URL points at a live proxy",
)
def verify_aider_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = AIDER_PORT
run(
["headroom", "wrap", "aider", "--port", str(port), "--", "--help"],
env=base_env,
cwd=project_dir,
timeout=120,
)
entries = read_jsonl(log_dir / "aider.jsonl")
assert_true(len(entries) > 0, "Aider shim should have been invoked")
env_vars = entries[-1]["env"]
# Aider cannot send custom headers, so its wrap embeds the launch
# directory as a /p/<name> base-URL prefix for per-project savings;
# the proxy strips it before routing, so the probes still succeed.
project_prefix = f"/p/{quote(project_dir.name, safe='')}"
assert_true(
env_vars.get("OPENAI_API_BASE") == f"http://127.0.0.1:{port}{project_prefix}/v1",
"Aider wrap should set OPENAI_API_BASE",
)
assert_true(
env_vars.get("ANTHROPIC_BASE_URL") == f"http://127.0.0.1:{port}{project_prefix}",
"Aider wrap should set ANTHROPIC_BASE_URL",
)
assert_true(
entries[-1]["probes"]
== [
{"url": f"http://127.0.0.1:{port}{project_prefix}/v1/models", "status": 200},
{"url": f"http://127.0.0.1:{port}{project_prefix}/health", "status": 200},
],
"Aider shim should prove both configured base URLs point at a live proxy",
)
def verify_cursor_wrap(base_env: dict[str, str], project_dir: Path) -> None:
port = CURSOR_PORT
proc = subprocess.Popen(
["headroom", "wrap", "cursor", "--port", str(port)],
env=base_env,
cwd=str(project_dir),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
errors="replace",
)
try:
output = wait_for_output(proc, "Press Ctrl+C to stop the proxy.", timeout=30)
# Cursor setup lines embed the /p/<name> per-project prefix.
cursor_prefix = f"/p/{quote(project_dir.name, safe='')}"
assert_true(
f"http://127.0.0.1:{port}{cursor_prefix}/v1" in output,
"Cursor wrap should print the OpenAI base URL override",
)
assert_true(
f"http://127.0.0.1:{port}" in output,
"Cursor wrap should print the Anthropic base URL override",
)
wait_for_http(f"http://127.0.0.1:{port}/health", timeout=15)
finally:
stop_process(proc)
def verify_cline_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap cline --prepare-only` exits clean.
These three wraps used to be verified by the hint-file guidance they wrote.
With the CLI context tools removed they produce no on-disk artifact, so the
remaining assertion is that the prepare path still runs without crashing —
``run`` raises on a non-zero exit.
"""
run(
["headroom", "wrap", "cline", "--prepare-only", "--port", str(CLINE_PORT)],
env=base_env,
cwd=project_dir,
timeout=60,
)
def verify_continue_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap continue --prepare-only` exits clean (see verify_cline_wrap)."""
run(
["headroom", "wrap", "continue", "--prepare-only", "--port", str(CONTINUE_PORT)],
env=base_env,
cwd=project_dir,
timeout=60,
)
def verify_goose_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap goose --prepare-only` exits clean (see verify_cline_wrap)."""
run(
["headroom", "wrap", "goose", "--prepare-only", "--port", str(GOOSE_PORT)],
env=base_env,
cwd=project_dir,
timeout=60,
)
def verify_openhands_wrap(base_env: dict[str, str], project_dir: Path) -> None:
"""Smoke test: `wrap openhands --prepare-only` exits clean.
This one is a real regression guard: openhands used to *require* the rtk
binary, so once rtk became opt-in the default path exited 1. Nothing is
stubbed here, so a reintroduced hard dependency fails the run.
"""
run(
["headroom", "wrap", "openhands", "--prepare-only", "--port", str(OPENHANDS_PORT)],
env=base_env,
cwd=project_dir,
timeout=60,
)
def verify_openclaw_wrap(
base_env: dict[str, str],
project_dir: Path,
plugin_dir: Path,
) -> None:
port = OPENCLAW_PROXY_PORT
gateway_proc: subprocess.Popen[str] | None = None
run(
[
"headroom",
"wrap",
"openclaw",
"--plugin-path",
str(plugin_dir),
"--proxy-port",
str(port),
"--startup-timeout-ms",
# 5s is too tight for cold Python+pyo3 import on a busy CI runner.
"30000",
"--verbose",
],
env=base_env,
cwd=project_dir,
timeout=600,
)
dist_index = plugin_dir / "dist" / "index.js"
assert_true(dist_index.exists(), "OpenClaw plugin build should produce dist/index.js")
config_file = run(["openclaw", "config", "file"], env=base_env, cwd=project_dir, timeout=60)
config_path_str = config_file.stdout.strip().splitlines()[-1].strip()
if config_path_str.startswith("~/"):
config_path = Path(base_env["HOME"]) / config_path_str[2:]
else:
config_path = Path(config_path_str)
assert_true(config_path.exists(), "OpenClaw should create a config file")
state = json.loads(config_path.read_text(encoding="utf-8"))
if state.get("gateway", {}).get("mode") != "local":
run(
[
"openclaw",
"config",
"set",
"gateway.mode",
json.dumps("local"),
"--strict-json",
],
env=base_env,
cwd=project_dir,
timeout=60,
)
state = json.loads(config_path.read_text(encoding="utf-8"))
try:
try:
wait_for_command_success(
["openclaw", "health"], env=base_env, cwd=project_dir, timeout=5
)
except RuntimeError:
gateway_proc = start_openclaw_gateway(base_env, project_dir)
try:
wait_for_command_success(
["openclaw", "health"], env=base_env, cwd=project_dir, timeout=30
)
except RuntimeError as exc:
gateway_output = ""
if gateway_proc.stdout is not None:
gateway_output = gateway_proc.stdout.read()
raise RuntimeError(f"{exc}\nGateway output:\n{gateway_output}") from exc
entry = state["plugins"]["entries"]["headroom"]
assert_true(entry["enabled"] is True, "OpenClaw wrap should enable the plugin")
assert_true(entry["config"]["proxyPort"] == port, "OpenClaw wrap should set proxy port")
assert_true(
entry["config"].get("autoStart", True) is True,
"OpenClaw wrap should leave autoStart enabled",
)
assert_true(
state["gateway"]["mode"] == "local",
"OpenClaw e2e bootstrap should set gateway.mode=local",
)
assert_true(
state["plugins"]["slots"]["contextEngine"] == "headroom",
"OpenClaw wrap should set the context engine slot",
)
finally:
if gateway_proc is not None:
stop_process(gateway_proc)
stop_openclaw_gateway(base_env, project_dir)
run(["headroom", "unwrap", "openclaw"], env=base_env, cwd=project_dir, timeout=120)
state = json.loads(config_path.read_text(encoding="utf-8"))
assert_true(
state["plugins"]["slots"]["contextEngine"] == "legacy",
"OpenClaw unwrap should restore the context engine slot",
)
def main() -> None:
verify_installs()
with tempfile.TemporaryDirectory(
prefix="headroom-wrap-e2e-", ignore_cleanup_errors=True
) as tmp_dir_str:
tmp_dir = Path(tmp_dir_str)
home_dir = tmp_dir / "home"
project_dir = tmp_dir / "project"
shim_dir = tmp_dir / "shim-bin"
log_dir = tmp_dir / "logs"
for path in (home_dir, project_dir, shim_dir, log_dir):
path.mkdir(parents=True, exist_ok=True)
create_shims(shim_dir)
mock_server, mock_thread = start_mock_server(19001)
base_env = os.environ.copy()
base_env.update(
{
"HOME": str(home_dir),
"PATH": f"{shim_dir}{os.pathsep}{base_env['PATH']}",
"HEADROOM_E2E_LOG_DIR": str(log_dir),
"OPENAI_TARGET_API_URL": "http://127.0.0.1:19001/v1",
}
)
try:
verify_proxy_round_trip(base_env, mock_server)
verify_claude_wrap(base_env, project_dir, log_dir)
verify_codex_wrap(base_env, project_dir, log_dir, mock_server)
verify_aider_wrap(base_env, project_dir, log_dir)
verify_cursor_wrap(base_env, project_dir)
verify_cline_wrap(base_env, project_dir)
verify_continue_wrap(base_env, project_dir)
verify_goose_wrap(base_env, project_dir)
verify_openhands_wrap(base_env, project_dir)
verify_opencode_wrap(base_env, project_dir, log_dir)
local_plugin_dir = prepare_local_openclaw_plugin(base_env, tmp_dir)
verify_openclaw_wrap(base_env, project_dir, local_plugin_dir)
finally:
mock_server.shutdown()
mock_thread.join(timeout=5)
log("All Docker wrap e2e checks passed.")
def verify_opencode_wrap(base_env: dict[str, str], project_dir: Path, log_dir: Path) -> None:
port = OPENCODE_PORT
run(
["headroom", "wrap", "opencode", "--port", str(port), "--", "--help"],
env=base_env,
cwd=project_dir,
timeout=120,
)
entries = read_jsonl(log_dir / "opencode.jsonl")
assert_true(len(entries) > 0, "Opencode shim should have been invoked")
env_vars = entries[-1]["env"]
assert_true(
env_vars.get("OPENCODE_CONFIG_CONTENT") is not None,
"Opencode wrap should set OPENCODE_CONFIG_CONTENT",
)
config = json.loads(env_vars["OPENCODE_CONFIG_CONTENT"])
assert_true(
config["provider"]["headroom"]["options"]["baseURL"] == f"http://127.0.0.1:{port}/v1",
"Opencode wrap should inject headroom provider baseURL",
)
run(
["headroom", "unwrap", "opencode", "--port", str(port)],
env=base_env,
cwd=project_dir,
timeout=120,
)
config_path = Path(base_env["HOME"]) / ".config" / "opencode" / "opencode.json"
if config_path.exists():
content = config_path.read_text(encoding="utf-8")
assert_true(
"headroom" not in content,
"Opencode unwrap should remove headroom provider from config",
)
if __name__ == "__main__":
main()