mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(wrap): remove rtk instructions from Codex AGENTS.md on unwrap (#1604)
## Description `headroom wrap codex` injects Headroom's marker-fenced rtk instruction block into the Codex **global** `AGENTS.md` (`_codex_home_dir() / "AGENTS.md"`), so Codex voluntarily prefixes shell commands with `rtk`. But `headroom unwrap codex` only restored `config.toml` and cleaned up the MCP/Serena servers — it never removed that `AGENTS.md` block. The result: after unwrapping, a plain `codex` launch still inherits Headroom's behavior and keeps trying to run `rtk`. If the managed rtk binary directory is no longer on `PATH`, commands fail outright: ```text rtk : The term 'rtk' is not recognized as the name of a cmdlet, function, script file, or operable program. Conversation interrupted ``` `unwrap copilot` already calls `_remove_rtk_instructions(...)`; Codex was simply missing the same cleanup step. Closes #1421 ## Fix Call the existing `_remove_rtk_instructions` helper on the Codex global `AGENTS.md` inside `unwrap_codex`, right after the MCP-server cleanup: ```python if _remove_rtk_instructions(_codex_home_dir() / "AGENTS.md"): click.echo(" Removed Headroom rtk instructions from Codex AGENTS.md.") ``` The helper strips only the marker-fenced block and rewrites the rest of the file (deleting it only if nothing else remains), so user-authored `AGENTS.md` content is preserved. The call is unconditional and best-effort, matching the existing MCP-server cleanup in the same function. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/cli/wrap.py`: `unwrap_codex` now removes the marker-fenced rtk block from the Codex global `AGENTS.md` via `_remove_rtk_instructions`, with a status echo. - `tests/test_cli/test_wrap_codex.py`: regression tests — block removed on unwrap, surrounding user content preserved, and a no-op when `AGENTS.md` is absent. - `CHANGELOG.md`: Bug Fixes entry under Unreleased. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [x] Manual testing performed ### Test Output Before the fix the two removal tests fail (the no-AGENTS.md safety test passes either way); after the fix the whole file is green: ```text # before the fix (wrap.py reverted, tests kept) FAILED tests/test_cli/test_wrap_codex.py::...::test_unwrap_removes_rtk_block_from_global_agents FAILED tests/test_cli/test_wrap_codex.py::...::test_unwrap_preserves_user_content_in_global_agents ================= 2 failed, 1 passed, 66 deselected in 1.00s ================== # after the fix tests\test_cli\test_wrap_codex.py ...................................... ............................... ============================= 69 passed in 7.45s ============================== ``` ```text $ uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_codex.py All checks passed! ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12.11, headroom built from this branch (`uv sync --extra dev`). - Exact command / steps: set `CODEX_HOME` to a temp dir, wrote a user `AGENTS.md`, injected the rtk block with the same helper `wrap codex` uses, then ran the real `unwrap codex` command (`unwrap_codex.callback(port=8787, no_stop_proxy=True)`) and re-read the file. No mocking of the code under test. - Observed result: the command printed `Removed Headroom rtk instructions from Codex AGENTS.md.`, the rtk marker is gone, and the user's own content survived: ```text === AGENTS.md BEFORE unwrap === # My rules Always write tests. <!-- headroom:rtk-instructions --> # RTK (Rust Token Killer) - Token-Optimized Commands ... <!-- /headroom:rtk-instructions --> rtk marker present before: True --- running: headroom unwrap codex --no-stop-proxy --- Removed Headroom rtk instructions from Codex AGENTS.md. ✓ Codex is no longer routed through the Headroom proxy. === AGENTS.md AFTER unwrap === # My rules Always write tests. rtk marker present after: False user content preserved: True ``` - Not tested: did not run a full real `codex` binary session end-to-end (not installed in this environment); the global-`AGENTS.md` state is the durable thing the bug was about, and it's exercised here for real. Did not run the full `mypy headroom` pass (one-line cleanup call, no new types). ## 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 - Single logical change, no new dependencies. Reuses the existing `_remove_rtk_instructions` helper, so there's no new removal logic to maintain. - @chopratejas this mirrors the `unwrap copilot` cleanup; flagging you since you've been triaging the wrap/unwrap issues. Co-authored-by: JD Davis <mxjerrett@gmail.com>
This commit is contained in:
parent
7fe203cfa1
commit
c9d717c13c
3 changed files with 58 additions and 0 deletions
|
|
@ -72,6 +72,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
|
||||
### Bug Fixes
|
||||
|
||||
* **wrap/codex:** `headroom unwrap codex` now removes the Headroom rtk instruction block from the Codex global `AGENTS.md`. `wrap codex` injects it there, but unwrap only restored `config.toml` and MCP state, so a plain `codex` launch kept following the "prefix shell commands with `rtk`" guidance and failed once the managed rtk binary was off PATH. Unwrap now strips the marker-fenced block (preserving the rest of the file), mirroring `unwrap copilot` ([#1421](https://github.com/headroomlabs-ai/headroom/issues/1421)).
|
||||
* **proxy/auth:** classify real Anthropic OAuth tokens correctly. `classify_auth_mode` matched OAuth on the `sk-ant-oat-` prefix, but real access tokens are `sk-ant-oat01-...` (a version number, no dash after `oat`), so every real subscription/OAuth token fell through to the `sk-` branch and was tagged `PAYG` — enabling aggressive lossy compression, auto `cache_control`, and `prompt_cache_key` injection on subscription-bound requests the classifier is meant to route to the passthrough-prefer path. The prefix is now the dash-less `sk-ant-oat` (still matches the legacy dashed shape). The existing parity tests only passed because they used a synthetic `sk-ant-oat-01-` fixture; a regression test now covers the real `sk-ant-oat01-` format.
|
||||
* **install:** stop leaking a file descriptor on every `headroom install start`. `start_detached_agent()` opened the agent log file and handed it to `subprocess.Popen` but never closed the parent's copy, so each call leaked one fd (and pinned the log file open against rotation). The parent now closes its copy in a `try/finally` once the child has inherited it — the close also runs if `Popen` raises ([#1554](https://github.com/headroomlabs-ai/headroom/issues/1554)).
|
||||
* **proxy:** include the system prompt, tools, and the response-shaping request fields in the SemanticCache key. `_compute_key` hashed only `{model, messages}`, so two non-streaming requests with identical messages but a different top-level `system` prompt, tool set, sampling config, or output-shaping field collided on one key and the second caller was served the first's cached response — generated under different request semantics, in the default config (`cache_enabled` defaults on). The key now folds the request fields that shape generation — `temperature`/`top_p`/`top_k`/`max_tokens`/`stop`, plus OpenAI `tool_choice`/`response_format`/`parallel_tool_calls`/`seed`/`presence_penalty`/`frequency_penalty`/`logit_bias`/`n`/`logprobs`/`top_logprobs`/`reasoning_effort`/`verbosity`/`modalities` and Anthropic `thinking`/`tool_choice`/`output_config` — canonicalizing `system`/`tools` so a moved `cache_control` breakpoint does not fragment it, and the handlers snapshot the fields once at the cache read and reuse them at write so a body mutated by the pipeline cannot diverge the key. Non-streaming path only.
|
||||
|
|
|
|||
|
|
@ -5842,6 +5842,16 @@ def unwrap_codex(port: int, no_stop_proxy: bool) -> None:
|
|||
elif serena_status == "failed":
|
||||
click.echo(" Serena MCP server matched Headroom ledger but could not be removed.")
|
||||
|
||||
# `wrap codex` injects the marker-fenced rtk guidance into the Codex global
|
||||
# AGENTS.md (`_codex_home_dir() / "AGENTS.md"`); that block is durable state
|
||||
# the config restore above does not touch. Without removing it, a plain
|
||||
# `codex` launch keeps following Headroom's "prefix shell commands with rtk"
|
||||
# instruction and fails when the managed rtk binary is off PATH. Mirror what
|
||||
# unwrap_copilot already does. Best-effort and unconditional, like the MCP
|
||||
# cleanup above.
|
||||
if _remove_rtk_instructions(_codex_home_dir() / "AGENTS.md"):
|
||||
click.echo(" Removed Headroom rtk instructions from Codex AGENTS.md.")
|
||||
|
||||
if status in {"restored", "cleaned", "removed"}:
|
||||
# Hand the threads back to the native-provider menu so the full history
|
||||
# stays visible once Codex no longer routes through Headroom. Best-effort.
|
||||
|
|
|
|||
|
|
@ -459,6 +459,53 @@ class TestInjectAndRestoreRoundTrip:
|
|||
assert status == "restored"
|
||||
assert config_file.read_text(encoding="utf-8") == malformed
|
||||
|
||||
def test_unwrap_removes_rtk_block_from_global_agents(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""`wrap codex` injects the rtk block into the Codex global AGENTS.md;
|
||||
`unwrap codex` must take it back out (regression for #1421)."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
agents = codex_home / "AGENTS.md"
|
||||
wrap_mod._inject_rtk_instructions(agents)
|
||||
assert wrap_mod._RTK_MARKER in agents.read_text(encoding="utf-8")
|
||||
|
||||
wrap_mod.unwrap_codex.callback(port=8787, no_stop_proxy=True)
|
||||
|
||||
remaining = agents.read_text(encoding="utf-8") if agents.exists() else ""
|
||||
assert wrap_mod._RTK_MARKER not in remaining
|
||||
|
||||
def test_unwrap_preserves_user_content_in_global_agents(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""Only the marker-fenced rtk block is removed; the user's own AGENTS.md
|
||||
prose survives the unwrap."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
agents = codex_home / "AGENTS.md"
|
||||
agents.write_text("# My project rules\n\nAlways write tests.\n", encoding="utf-8")
|
||||
wrap_mod._inject_rtk_instructions(agents)
|
||||
assert wrap_mod._RTK_MARKER in agents.read_text(encoding="utf-8")
|
||||
|
||||
wrap_mod.unwrap_codex.callback(port=8787, no_stop_proxy=True)
|
||||
|
||||
remaining = agents.read_text(encoding="utf-8")
|
||||
assert wrap_mod._RTK_MARKER not in remaining
|
||||
assert "# My project rules" in remaining
|
||||
assert "Always write tests." in remaining
|
||||
|
||||
def test_unwrap_is_safe_when_no_global_agents(
|
||||
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||
) -> None:
|
||||
"""No Codex AGENTS.md → unwrap is a clean no-op, not a crash."""
|
||||
_set_test_home(monkeypatch, tmp_path)
|
||||
|
||||
wrap_mod.unwrap_codex.callback(port=8787, no_stop_proxy=True)
|
||||
|
||||
assert not (tmp_path / ".codex" / "AGENTS.md").exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thread retag: wrap pulls native threads into the headroom menu, unwrap hands
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue