fix(codex): retag thread providers so history menu stays whole across the proxy boundary (#1034)

## Description

Codex stamps every thread with the `model_provider` it ran under and
filters its
history/projects menu by the currently active provider set. When
Headroom rewrites
Codex's config to route through the custom `headroom` provider, threads
created through
Headroom are tagged `headroom` while native threads keep `openai` — so
the two sets
never appear in the same menu. The visible symptom: enabling Headroom
appears to "lose"
the entire native Codex history, and disabling it hides everything
created while wrapped.

This reconciles the thread tags in Codex's SQLite store alongside the
existing config
edits, so the menu stays whole across the proxy boundary in both
directions:
`openai -> headroom` on enable/wrap, `headroom -> openai` on
revert/unwrap. Only rows
matching the source provider are touched, so third-party providers (e.g.
`anthropic`)
are left alone. The provider key cannot be unified by config — Codex
rejects naming a
custom provider `openai` ("reserved built-in provider IDs") — so
retagging the store is
the only path. A DB-only retag is sufficient: resuming a retagged
session still routes
completions through the active provider; the rollout `.jsonl` files do
not need
rewriting.

Every operation is best-effort: a missing store, a missing `threads`
table, or a corrupt
store is logged and skipped, never raised, so install/uninstall and
wrap/unwrap never
fail on account of the history menu. The store is WAL-mode, so the
update succeeds even
while Codex is running; the short busy timeout only covers a transient
checkpoint lock.

Closes #

## 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

- New `headroom/providers/codex/threads.py`: best-effort retag of Codex
thread provider
tags across both known stores (`<codex_home>/sqlite/state_5.sqlite` for
the GUI and
`<codex_home>/state_5.sqlite` for the CLI/TUI). `retag_to_headroom` /
`retag_to_native`
wrap the directional helper. `codex_home` is passed in by callers (never
re-derived from
  `Path.home()`), so tests stay pointed at a temp dir.
- `providers/codex/install.py`: `apply_provider_scope` calls
`retag_to_headroom` after
writing the provider block; `revert_provider_scope` calls
`retag_to_native` after
  stripping it.
- `cli/wrap.py`: `_inject_codex_provider_config` calls
`retag_to_headroom`;
`unwrap_codex` calls `retag_to_native` once the config restore reports a
  `restored`/`cleaned`/`removed` status.
- Tests: `tests/test_provider_codex_threads.py` (retag direction,
threads-table no-op,
missing/corrupt store best-effort) and a wrap/unwrap round-trip
integration test in
  `tests/test_cli/test_wrap_codex.py`.

## Testing

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

### Test Output

```text
$ uv run --extra dev pytest tests/test_provider_codex_threads.py tests/test_cli/test_wrap_codex.py tests/test_install/test_providers.py -q
... 88 passed, 2 failed
# The 2 failures are TestInjectAvoidsDuplicateTopLevelKeys::* — pre-existing on a clean
# upstream/main checkout, unrelated to this change: they `import tomllib`, which is
# stdlib only on Python 3.11+, and this environment runs Python 3.10.18.

$ uv run --extra dev ruff check headroom/cli/wrap.py headroom/providers/codex/threads.py \
    headroom/providers/codex/install.py tests/test_cli/test_wrap_codex.py tests/test_provider_codex_threads.py
All checks passed!

$ uv run --extra dev mypy headroom/providers/codex/threads.py headroom/providers/codex/install.py
Success: no issues found in 2 source files
```

## Real Behavior Proof

- Environment: macOS, Codex GUI v148 + Codex CLI, Python 3.10.18.
- Exact command / steps: The root cause and fix were confirmed live in
the Headroom
desktop app, which performs the identical SQLite retag. Connecting Codex
to Headroom
  hid ~140 native (`openai`) threads from the history menu; running
`UPDATE threads SET model_provider='headroom' WHERE
model_provider='openai'` on the live
store (`~/.codex/sqlite/state_5.sqlite`) made the full menu reappear,
and resuming a
retagged session still routed completions through the active provider.
This Python port
is a 1:1 of that logic, exercised by the unit + wrap/unwrap integration
tests above.
- Observed result: full Codex history menu restored across
enable/disable; third-party
provider rows untouched; the real `~/.codex` stores were snapshotted
before/after the
  test run and were not mutated by the tests.
- Not tested: an end-to-end `headroom wrap codex` run against a live
Codex GUI in this CI
environment (no Codex install here); covered instead by the integration
test invoking
  the real `wrap`/`unwrap` Click commands against a temp `$HOME`.

## 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
- [ ] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A — behavior is in Codex's own history menu; covered by the proof
above.

## Additional Notes

- Documentation / CHANGELOG: N/A — internal behavior with no user-facing
surface beyond
  the restored menu.
- The 2 failing `TestInjectAvoidsDuplicateTopLevelKeys` tests are
pre-existing on
upstream/main and fail only because this environment runs Python 3.10
(no `tomllib`);
  they are unrelated to this change.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gglucass 2026-06-16 22:13:21 +02:00 committed by GitHub
parent c65e321ea2
commit 74ae781644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 250 additions and 0 deletions

View file

@ -52,6 +52,7 @@ from headroom.providers.aider import build_launch_env as _build_aider_launch_env
from headroom.providers.claude import proxy_base_url as _claude_proxy_base_url
from headroom.providers.codex import build_launch_env as _build_codex_launch_env
from headroom.providers.codex.install import codex_uses_chatgpt_auth
from headroom.providers.codex.threads import retag_to_headroom, retag_to_native
from headroom.providers.copilot import (
build_launch_env as _build_copilot_launch_env,
)
@ -1249,6 +1250,9 @@ def _inject_codex_provider_config(port: int) -> None:
config_file.write_text(content)
click.echo(f" Codex config: injected Headroom provider (WS + HTTP) into {config_file}")
# Pull existing native threads into the headroom-provider menu so Codex's
# history list stays whole once it routes through Headroom. Best-effort.
retag_to_headroom(_codex_home_dir())
except Exception as e:
click.echo(f" Warning: could not update Codex config: {e}")
@ -4685,6 +4689,11 @@ 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.")
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.
retag_to_native(_codex_home_dir())
click.echo()
click.echo("✓ Codex is no longer routed through the Headroom proxy.")
if not no_stop_proxy and status != "noop":

View file

@ -10,6 +10,7 @@ from headroom.install.models import ConfigScope, DeploymentManifest, ManagedMuta
from headroom.install.paths import codex_config_path
from .runtime import proxy_base_url
from .threads import retag_to_headroom, retag_to_native
_CODEX_MARKER_START = "# --- Headroom persistent provider ---"
_CODEX_MARKER_END = "# --- end Headroom persistent provider ---"
@ -119,6 +120,9 @@ def apply_provider_scope(manifest: DeploymentManifest) -> ManagedMutation | None
else:
merged = section + "\n"
path.write_text(merged, encoding="utf-8")
# Pull existing native threads into the headroom-provider menu so Codex's
# history list stays whole once it routes through Headroom. Best-effort.
retag_to_headroom(path.parent)
return ManagedMutation(target=ToolTarget.CODEX.value, kind="toml-block", path=str(path))
@ -140,3 +144,6 @@ def revert_provider_scope(mutation: ManagedMutation, manifest: DeploymentManifes
content = _ORPHAN_OPENAI_BASE_URL.sub("", content)
content = _ORPHAN_HEADROOM_TABLE.sub("", content)
path.write_text(content.strip() + "\n", encoding="utf-8")
# Hand the threads back to the native-provider menu so the full history stays
# visible once Codex no longer routes through Headroom. Best-effort.
retag_to_native(path.parent)

View file

@ -0,0 +1,97 @@
"""Reconcile Codex thread provider tags across the Headroom proxy boundary.
Codex stamps every thread with the ``model_provider`` it ran under and filters
its history/projects menu by the active provider set. When Headroom rewrites
Codex's config to route through the custom ``headroom`` provider (see
:mod:`headroom.providers.codex.install`), threads created through Headroom are
tagged ``headroom`` while native threads keep ``openai`` -- so the two sets never
appear in the same menu, and connecting Headroom appears to "lose" history.
To keep the menu whole we retag threads to match whichever provider is active:
``openai -> headroom`` when Headroom is enabled, ``headroom -> openai`` when it is
reverted. Only rows whose ``model_provider`` equals the source value are
touched, so third-party providers are left alone.
Every operation is best-effort: a missing store, a missing ``threads`` table, or
a store momentarily locked by a running Codex is logged and skipped -- never
raised -- so install/uninstall never fail on account of the history menu. The
store is WAL-mode, so the update succeeds even while Codex is running; the short
busy timeout only covers a transient checkpoint lock.
"""
from __future__ import annotations
import logging
import sqlite3
from pathlib import Path
logger = logging.getLogger(__name__)
HEADROOM_PROVIDER = "headroom"
NATIVE_PROVIDER = "openai"
# Seconds to wait on a busy store before giving up (a running Codex only holds an
# exclusive lock briefly, during a WAL checkpoint).
_BUSY_TIMEOUT_S = 0.75
def _codex_state_db_paths(codex_home: Path) -> list[Path]:
"""Both known Codex state stores under ``codex_home`` (the ``.codex`` dir).
The v148 desktop GUI reads ``<codex_home>/sqlite/state_5.sqlite``; the
CLI/TUI uses ``<codex_home>/state_5.sqlite``. Retag whichever exist.
"""
return [codex_home / "sqlite" / "state_5.sqlite", codex_home / "state_5.sqlite"]
def _retag_one(path: Path, *, frm: str, to: str) -> int:
"""Retag a single store and return the number of rows moved.
No-ops (returns 0) on a store whose schema lacks the ``threads`` table.
"""
conn = sqlite3.connect(str(path), timeout=_BUSY_TIMEOUT_S)
try:
has_table = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'threads'"
).fetchone()
if has_table is None:
return 0
cur = conn.execute(
"UPDATE threads SET model_provider = ? WHERE model_provider = ?",
(to, frm),
)
conn.commit()
return cur.rowcount
finally:
conn.close()
def retag_thread_providers(codex_home: Path, *, frm: str, to: str) -> None:
"""Best-effort retag of Codex thread provider tags across all known stores.
``codex_home`` is the Codex configuration directory (the parent of
``config.toml``); resolving from it keeps callers and tests pointed at one
location rather than re-deriving ``~/.codex`` independently.
"""
if frm == to:
return
for path in _codex_state_db_paths(codex_home):
if not path.exists():
continue
try:
moved = _retag_one(path, frm=frm, to=to)
except sqlite3.Error as exc:
logger.warning("codex thread retag %s->%s skipped for %s: %s", frm, to, path, exc)
continue
if moved:
logger.info("codex thread retag %s->%s: %d thread(s) in %s", frm, to, moved, path)
def retag_to_headroom(codex_home: Path) -> None:
"""Pull existing native threads into the headroom-provider menu (on enable)."""
retag_thread_providers(codex_home, frm=NATIVE_PROVIDER, to=HEADROOM_PROVIDER)
def retag_to_native(codex_home: Path) -> None:
"""Hand threads back to the native-provider menu (on revert)."""
retag_thread_providers(codex_home, frm=HEADROOM_PROVIDER, to=NATIVE_PROVIDER)

View file

@ -9,6 +9,7 @@ way a user would from the shell.
from __future__ import annotations
import sqlite3
from pathlib import Path
from unittest.mock import patch
@ -329,6 +330,66 @@ class TestInjectAndRestoreRoundTrip:
assert config_file.read_text() == malformed
# ---------------------------------------------------------------------------
# Thread retag: wrap pulls native threads into the headroom menu, unwrap hands
# them back, so the Codex history list stays whole across the proxy boundary.
# ---------------------------------------------------------------------------
class TestWrapRetagsThreadProviders:
"""``wrap codex`` retags ``openai`` threads to ``headroom`` and back."""
@staticmethod
def _seed_threads(db: Path, rows: list[tuple[str, str]]) -> None:
db.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db))
try:
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL)")
conn.executemany("INSERT INTO threads (id, model_provider) VALUES (?, ?)", rows)
conn.commit()
finally:
conn.close()
@staticmethod
def _count(db: Path, provider: str) -> int:
conn = sqlite3.connect(str(db))
try:
(n,) = conn.execute(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?", (provider,)
).fetchone()
return n
finally:
conn.close()
def test_wrap_unwrap_round_trips_thread_providers(
self, runner: CliRunner, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
_set_test_home(monkeypatch, tmp_path)
gui_db = tmp_path / ".codex" / "sqlite" / "state_5.sqlite"
cli_db = tmp_path / ".codex" / "state_5.sqlite"
self._seed_threads(gui_db, [("a", "openai"), ("b", "headroom"), ("c", "anthropic")])
self._seed_threads(cli_db, [("d", "openai")])
with patch("headroom.cli.wrap._ensure_rtk_binary", return_value=None):
wrap_result = runner.invoke(main, ["wrap", "codex", "--prepare-only", "--port", "8787"])
assert wrap_result.exit_code == 0, wrap_result.output
# Native threads are now visible under the headroom provider menu;
# third-party providers are left untouched.
assert self._count(gui_db, "headroom") == 2
assert self._count(gui_db, "openai") == 0
assert self._count(gui_db, "anthropic") == 1
assert self._count(cli_db, "headroom") == 1
with patch("headroom.cli.wrap._stop_local_proxy_for_unwrap", return_value="stopped"):
unwrap_result = runner.invoke(main, ["unwrap", "codex", "--port", "8787"])
assert unwrap_result.exit_code == 0, unwrap_result.output
# Back to native so the unproxied Codex menu is whole again.
assert self._count(gui_db, "openai") == 2
assert self._count(gui_db, "headroom") == 0
assert self._count(gui_db, "anthropic") == 1
assert self._count(cli_db, "openai") == 1
# ---------------------------------------------------------------------------
# Subscription routing: openai_base_url intercepts ChatGPT plan traffic
# ---------------------------------------------------------------------------

View file

@ -0,0 +1,76 @@
from __future__ import annotations
import sqlite3
from pathlib import Path
from headroom.providers.codex import threads
def _seed(path: Path, rows: list[tuple[str, str]]) -> None:
conn = sqlite3.connect(str(path))
try:
conn.execute("CREATE TABLE threads (id TEXT PRIMARY KEY, model_provider TEXT NOT NULL)")
conn.executemany("INSERT INTO threads (id, model_provider) VALUES (?, ?)", rows)
conn.commit()
finally:
conn.close()
def _count(path: Path, provider: str) -> int:
conn = sqlite3.connect(str(path))
try:
(n,) = conn.execute(
"SELECT COUNT(*) FROM threads WHERE model_provider = ?", (provider,)
).fetchone()
return n
finally:
conn.close()
def test_retag_one_moves_only_matching_provider(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
_seed(db, [("a", "openai"), ("b", "openai"), ("c", "headroom"), ("d", "anthropic")])
moved = threads._retag_one(db, frm="openai", to="headroom")
assert moved == 2
assert _count(db, "openai") == 0
assert _count(db, "headroom") == 3
# Third-party providers are left alone.
assert _count(db, "anthropic") == 1
back = threads._retag_one(db, frm="headroom", to="openai")
assert back == 3
assert _count(db, "headroom") == 0
assert _count(db, "openai") == 3
assert _count(db, "anthropic") == 1
def test_retag_one_noop_without_threads_table(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
sqlite3.connect(str(db)).close() # empty schema, no threads table
assert threads._retag_one(db, frm="openai", to="headroom") == 0
def test_retag_thread_providers_silent_when_no_store(tmp_path: Path) -> None:
# No stores exist under this codex_home: must not raise.
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
def test_retag_thread_providers_best_effort_on_corrupt_store(tmp_path: Path) -> None:
bad = tmp_path / "state_5.sqlite"
bad.write_text("not a sqlite database", encoding="utf-8")
# A corrupt store is logged and skipped, never raised.
threads.retag_thread_providers(tmp_path, frm="openai", to="headroom")
def test_enable_disable_wrappers_retag_expected_direction(tmp_path: Path) -> None:
db = tmp_path / "state_5.sqlite"
_seed(db, [("a", "openai"), ("b", "headroom")])
threads.retag_to_headroom(tmp_path)
assert _count(db, "headroom") == 2
assert _count(db, "openai") == 0
threads.retag_to_native(tmp_path)
assert _count(db, "openai") == 2
assert _count(db, "headroom") == 0