2026-04-23 15:55:12 -05:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import importlib
|
|
|
|
|
import json
|
2026-06-12 07:59:31 +08:00
|
|
|
import os
|
2026-04-23 15:55:12 -05:00
|
|
|
import sys
|
|
|
|
|
import types
|
2026-06-11 10:34:43 +09:00
|
|
|
from contextlib import contextmanager
|
2026-04-23 15:55:12 -05:00
|
|
|
from pathlib import Path
|
|
|
|
|
from types import SimpleNamespace
|
|
|
|
|
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
try:
|
|
|
|
|
import tomllib
|
|
|
|
|
except ModuleNotFoundError: # Python < 3.11
|
|
|
|
|
import tomli as tomllib # type: ignore[no-redef]
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
import click
|
|
|
|
|
import pytest
|
|
|
|
|
from click.testing import CliRunner
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_init_module(monkeypatch):
|
|
|
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
|
|
|
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.main", raising=False)
|
|
|
|
|
fake_main_module = types.ModuleType("headroom.cli.main")
|
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
|
|
def fake_main() -> None:
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
fake_main_module.main = fake_main
|
|
|
|
|
monkeypatch.setitem(sys.modules, "headroom.cli.main", fake_main_module)
|
|
|
|
|
importlib.invalidate_caches()
|
|
|
|
|
init_cli = importlib.import_module("headroom.cli.init")
|
|
|
|
|
monkeypatch.delitem(sys.modules, "headroom.cli.init", raising=False)
|
|
|
|
|
return init_cli, fake_main
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_auto_detects_targets(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "detect_init_targets", lambda global_scope: ["claude", "codex"])
|
|
|
|
|
monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "-g"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert captured["targets"] == ["claude", "codex"]
|
|
|
|
|
assert captured["global_scope"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_fails_when_auto_detection_empty(monkeypatch) -> None:
|
|
|
|
|
"""Bare ``headroom init`` with no agents on PATH prints a guided error.
|
|
|
|
|
|
|
|
|
|
Regression guard for issue #245: the error must list every target that
|
|
|
|
|
was probed, confirm that -g / --global is a valid flag, and show the
|
|
|
|
|
explicit per-target invocation so the user knows how to proceed.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "-g"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code != 0
|
|
|
|
|
assert "No supported user-scope agents were found on PATH" in result.output
|
|
|
|
|
assert "probed the following agents" in result.output
|
|
|
|
|
# Every in-scope target is listed with its lookup status.
|
|
|
|
|
for target in ("claude", "codex", "copilot", "openclaw"):
|
|
|
|
|
assert target in result.output
|
|
|
|
|
# The user is told that -g is still valid and given a concrete next step.
|
|
|
|
|
assert "-g" in result.output
|
|
|
|
|
assert "headroom init -g claude" in result.output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_empty_detection_error_local_scope(monkeypatch) -> None:
|
|
|
|
|
"""Local-scope variant of the guided error only lists local-scope agents."""
|
|
|
|
|
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
|
|
|
|
|
|
message = init_cli._format_empty_detection_error(global_scope=False)
|
|
|
|
|
|
|
|
|
|
assert "local-scope agents" in message
|
|
|
|
|
assert "claude" in message and "codex" in message
|
|
|
|
|
# Copilot / openclaw are global-only; must not be suggested for local.
|
|
|
|
|
assert "headroom init copilot" not in message
|
|
|
|
|
assert "headroom init openclaw" not in message
|
|
|
|
|
assert "headroom init claude" in message
|
|
|
|
|
assert "headroom init codex" in message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_format_empty_detection_error_reports_found_paths(monkeypatch, tmp_path) -> None:
|
|
|
|
|
"""When a binary IS present, the error still surfaces its path for debugging."""
|
|
|
|
|
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
fake_claude = tmp_path / "claude"
|
|
|
|
|
fake_claude.write_text("")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli.shutil,
|
|
|
|
|
"which",
|
|
|
|
|
lambda name: str(fake_claude) if name == "claude" else None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
message = init_cli._format_empty_detection_error(global_scope=True)
|
|
|
|
|
|
|
|
|
|
assert f"claude: found at {fake_claude}" in message
|
|
|
|
|
assert "codex: not found" in message
|
|
|
|
|
|
|
|
|
|
|
feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.
Instrumented decision points:
* detect_init_targets / _probe_init_targets — scope + per-target
shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
_ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand
Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.
The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.
Added tests cover:
* ``init -v`` emits the expected markers to stderr, including
``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
remains singular)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00
|
|
|
def test_init_verbose_enables_debug_logging_on_stderr(monkeypatch) -> None:
|
2026-04-23 16:19:17 -05:00
|
|
|
"""``headroom init -v`` should emit diagnostic lines to stderr.
|
|
|
|
|
|
|
|
|
|
Different Click 8.x versions expose stderr on ``CliRunner`` results
|
|
|
|
|
differently (``mix_stderr`` was removed in 8.2, and ``result.stderr``
|
|
|
|
|
appeared around the same time). To stay compatible with any Click 8.x
|
|
|
|
|
the repo targets, the test reads ``result.stderr`` when the attribute
|
|
|
|
|
exists AND contains data, otherwise falls back to ``result.output``
|
|
|
|
|
(which is the combined stream when stderr isn't captured separately).
|
|
|
|
|
"""
|
feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.
Instrumented decision points:
* detect_init_targets / _probe_init_targets — scope + per-target
shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
_ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand
Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.
The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.
Added tests cover:
* ``init -v`` emits the expected markers to stderr, including
``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
remains singular)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00
|
|
|
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
2026-04-23 16:19:17 -05:00
|
|
|
runner = CliRunner()
|
feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.
Instrumented decision points:
* detect_init_targets / _probe_init_targets — scope + per-target
shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
_ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand
Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.
The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.
Added tests cover:
* ``init -v`` emits the expected markers to stderr, including
``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
remains singular)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "-v", "-g"])
|
|
|
|
|
|
2026-04-23 16:19:17 -05:00
|
|
|
# Newer Click: stderr captured separately.
|
|
|
|
|
stderr = getattr(result, "stderr", None) or ""
|
|
|
|
|
if not stderr:
|
|
|
|
|
# Older Click: everything in result.output.
|
|
|
|
|
stderr = result.output
|
|
|
|
|
|
|
|
|
|
assert result.exit_code != 0, f"output: {result.output!r}"
|
|
|
|
|
assert "[headroom init]" in stderr
|
|
|
|
|
assert "detect_init_targets" in stderr
|
|
|
|
|
assert "global_scope=True" in stderr
|
feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.
Instrumented decision points:
* detect_init_targets / _probe_init_targets — scope + per-target
shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
_ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand
Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.
The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.
Added tests cover:
* ``init -v`` emits the expected markers to stderr, including
``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
remains singular)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00
|
|
|
for target in ("claude", "codex", "copilot", "openclaw"):
|
2026-04-23 16:19:17 -05:00
|
|
|
assert target in stderr
|
feat(init): add -v/--verbose flag for debug diagnostics
When users hit an init regression it's opaque why: no visible state
about which agents were probed, which paths were written, which
subprocesses ran. Add a top-level flag to ``headroom init`` that routes
debug-level logging from the ``headroom.cli.init`` logger to stderr.
Instrumented decision points:
* detect_init_targets / _probe_init_targets — scope + per-target
shutil.which result
* _write_json, _ensure_claude_hooks, _ensure_copilot_hooks,
_ensure_codex_hooks, _ensure_codex_provider — file paths being written
* _apply_user_env — chosen scope (windows vs unix) and env-var keys
* _run_checked — each subprocess command + exit code + truncated
stdout/stderr (useful when ``claude plugin install`` fails)
* _run_init_targets — target dispatch order and resolved profile
* top-level init callback — all flag values and invoked_subcommand
Log output goes to stderr so stdout stays clean for pipes. The handler
attached by ``_enable_verbose_logging`` is idempotent - nested
subcommand invocations don't duplicate output. The logger does not
propagate to the root logger, so enabling ``headroom init -v`` does not
affect the rest of the process.
The flag is declared on the parent Click group. Subcommands (claude,
codex, copilot, openclaw) inherit the enabled logger automatically
because the group callback runs before dispatch.
Added tests cover:
* ``init -v`` emits the expected markers to stderr, including
``detect_init_targets``, ``global_scope=True``, and each agent name
* ``_enable_verbose_logging`` is safe to call repeatedly (handler
remains singular)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:59:57 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_verbose_is_idempotent(monkeypatch) -> None:
|
|
|
|
|
"""Calling _enable_verbose_logging repeatedly keeps one handler attached."""
|
|
|
|
|
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
# Clear any prior handler state on the dedicated init logger.
|
|
|
|
|
init_cli.logger.handlers.clear()
|
|
|
|
|
if hasattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR):
|
|
|
|
|
delattr(init_cli.logger, init_cli._VERBOSE_HANDLER_ATTR)
|
|
|
|
|
|
|
|
|
|
init_cli._enable_verbose_logging()
|
|
|
|
|
init_cli._enable_verbose_logging()
|
|
|
|
|
init_cli._enable_verbose_logging()
|
|
|
|
|
|
|
|
|
|
assert len(init_cli.logger.handlers) == 1
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_init_copilot_requires_global(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-test")
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "copilot"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code != 0
|
|
|
|
|
assert "requires -g" in result.output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_claude_local_writes_settings_and_installs_marketplace(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
marketplace_calls: list[str] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-local-demo")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"_install_claude_marketplace",
|
|
|
|
|
lambda scope: marketplace_calls.append(scope),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "claude"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
settings_path = tmp_path / ".claude" / "settings.local.json"
|
|
|
|
|
payload = json.loads(settings_path.read_text(encoding="utf-8"))
|
|
|
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787"
|
|
|
|
|
assert marketplace_calls == ["local"]
|
|
|
|
|
assert any(
|
|
|
|
|
"--profile init-local-demo" in hook["command"] and "init hook ensure" in hook["command"]
|
|
|
|
|
for entry in payload["hooks"]["SessionStart"]
|
|
|
|
|
for hook in entry["hooks"]
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_codex_merges_feature_flag_into_existing_table(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
config_path = tmp_path / ".codex" / "config.toml"
|
|
|
|
|
config_path.parent.mkdir(parents=True)
|
|
|
|
|
config_path.write_text("[features]\nshell_tool = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000)
|
|
|
|
|
|
|
|
|
|
content = config_path.read_text(encoding="utf-8")
|
|
|
|
|
assert 'base_url = "http://127.0.0.1:9000/v1"' in content
|
|
|
|
|
assert content.count("[features]") == 1
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
assert "hooks = true" in content
|
2026-05-05 21:03:42 -05:00
|
|
|
assert 'env_key = "OPENAI_API_KEY"' not in content
|
2026-04-23 15:55:12 -05:00
|
|
|
hooks = json.loads((tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert "--profile init-local-demo" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
|
|
|
|
assert "init hook ensure" in hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
|
|
|
|
|
|
|
|
|
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
def test_init_codex_creates_hooks_feature_flag_on_first_init(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
|
|
|
|
|
init_cli._init_codex(global_scope=False, profile="init-local-demo", port=9000)
|
|
|
|
|
|
|
|
|
|
content = (tmp_path / ".codex" / "config.toml").read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["model_provider"] == "headroom"
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert "codex_hooks" not in content
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_init_claude_uses_custom_port(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_install_claude_marketplace", lambda scope: None)
|
|
|
|
|
|
|
|
|
|
init_cli._init_claude(global_scope=False, profile="init-local-demo", port=9011)
|
|
|
|
|
|
|
|
|
|
payload = json.loads((tmp_path / ".claude" / "settings.local.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:9011"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_copilot_global_writes_hooks_and_env(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
captured_env: dict[str, str] = {}
|
|
|
|
|
monkeypatch.setattr(init_cli, "_copilot_config_path", lambda: tmp_path / "copilot-config.json")
|
|
|
|
|
monkeypatch.setattr(init_cli, "_apply_user_env", lambda values: captured_env.update(values))
|
|
|
|
|
monkeypatch.setattr(init_cli, "_install_copilot_marketplace", lambda: None)
|
|
|
|
|
|
|
|
|
|
init_cli._init_copilot(global_scope=True, profile="init-user", port=9005, backend="openai")
|
|
|
|
|
|
|
|
|
|
payload = json.loads((tmp_path / "copilot-config.json").read_text(encoding="utf-8"))
|
|
|
|
|
assert "SessionStart" in payload["hooks"]
|
|
|
|
|
assert "PreToolUse" in payload["hooks"]
|
|
|
|
|
assert "--profile init-user" in payload["hooks"]["SessionStart"][0]["command"]
|
|
|
|
|
assert captured_env == {
|
|
|
|
|
"COPILOT_PROVIDER_TYPE": "openai",
|
|
|
|
|
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9005/v1",
|
|
|
|
|
"COPILOT_PROVIDER_WIRE_API": "completions",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_hook_ensure_prefers_local_profile(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
ensured: list[str] = []
|
|
|
|
|
|
|
|
|
|
def fake_load(profile: str):
|
|
|
|
|
return object() if profile == "init-repo-12345678" else None
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", fake_load)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert ensured == ["init-repo-12345678"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_openclaw_requires_global(monkeypatch) -> None:
|
|
|
|
|
_, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "openclaw"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code != 0
|
|
|
|
|
assert "requires -g" in result.output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_openclaw_delegates_to_wrap(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
calls: list[list[str]] = []
|
|
|
|
|
|
|
|
|
|
class _Result:
|
|
|
|
|
returncode = 0
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli.subprocess,
|
|
|
|
|
"run",
|
|
|
|
|
lambda cmd: calls.append(cmd) or _Result(),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._init_openclaw(global_scope=True, port=9999)
|
|
|
|
|
|
|
|
|
|
assert calls == [["headroom", "wrap", "openclaw", "--proxy-port", "9999"]]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_detect_init_targets_respects_scope(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli.shutil,
|
|
|
|
|
"which",
|
|
|
|
|
lambda name: name if name in {"claude", "copilot", "codex", "openclaw"} else None,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert init_cli.detect_init_targets(False) == ["claude", "codex"]
|
|
|
|
|
assert init_cli.detect_init_targets(True) == ["claude", "copilot", "codex", "openclaw"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_marketplace_source_prefers_env_override(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setenv("HEADROOM_MARKETPLACE_SOURCE", "custom/source")
|
|
|
|
|
|
|
|
|
|
assert init_cli._marketplace_source() == "custom/source"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_checked_treats_existing_install_as_success(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
class _Result:
|
|
|
|
|
returncode = 1
|
|
|
|
|
stderr = "plugin already exists"
|
|
|
|
|
stdout = ""
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())
|
|
|
|
|
|
|
|
|
|
init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_command_string_and_matcher_on_windows(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "list2cmdline", lambda parts: "joined-command")
|
|
|
|
|
|
|
|
|
|
assert init_cli._command_string(["headroom", "init"]) == "joined-command"
|
|
|
|
|
assert init_cli._powershell_matcher() == "Bash|PowerShell"
|
|
|
|
|
|
|
|
|
|
|
fix(init): normalize Windows hook paths to forward slashes (#788)
## Description
On Windows, `_command_string()` preserves backslash paths from
`shutil.which()` (e.g. `C:\Users\...\headroom.exe`). Claude Code
executes hooks via Git Bash, which interprets backslashes as escape
characters, corrupting the path and failing with "command not found".
This PR normalizes backslash separators to forward slashes before
passing parts to `subprocess.list2cmdline()`. Forward slashes work in
bash, PowerShell, and cmd.exe on Windows.
Fixes #724
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `headroom/cli/init.py`: Normalize backslash path separators to forward
slashes in `_command_string()` on Windows, before calling
`subprocess.list2cmdline()`
- `tests/test_cli/test_init_cli.py`: Add
`test_command_string_normalizes_backslashes_on_windows` verifying no
backslashes remain in the output and the forward-slash path is preserved
## Real behavior proof
**Setup:** Windows 11 (build 26200), Python 3.10.18, headroom repo at
commit 9579567
**Before fix** — `_command_string()` output with a typical Windows path:
```
C:\Users\sheng\.local\bin\headroom.exe init hook ensure --profile default
```
Git Bash interprets `\U`, `\s`, `\.`, `\b`, `\h` as escape sequences →
command not found.
**After fix** — same input, normalized output:
```
C:/Users/sheng/.local/bin/headroom.exe init hook ensure --profile default
```
Forward slashes pass through Git Bash, PowerShell, and cmd.exe without
corruption.
**Edge case — path with spaces** (quoting preserved):
```
"C:/Program Files/headroom/headroom.exe" init hook ensure
```
**What I did not test:** Live `headroom init claude` end-to-end
(headroom native extension build fails on this machine due to Rust
download timeout). The fix is exercised by the unit test which uses the
real `subprocess.list2cmdline` on Windows.
## Testing
- [x] Unit tests pass (`pytest`) — 50/50 passed in `test_init_cli.py`
- [x] Linting passes (`ruff check .`)
- [x] Formatting passes (`ruff format --check .`)
- [x] New tests added for new functionality
## Test Output
```
$ python -m pytest tests/test_cli/test_init_cli.py -v
50 passed, 3 warnings in 4.02s
```
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-11 09:55:43 +08:00
|
|
|
def test_command_string_normalizes_backslashes_on_windows(monkeypatch) -> None:
|
|
|
|
|
"""Backslash paths must become forward slashes so Git Bash hooks work (#724)."""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
|
|
|
|
|
|
|
|
result = init_cli._command_string(
|
|
|
|
|
["C:\\Users\\user\\.local\\bin\\headroom.exe", "init", "hook", "ensure"]
|
|
|
|
|
)
|
|
|
|
|
assert "\\" not in result
|
|
|
|
|
assert "C:/Users/user/.local/bin/headroom.exe" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_command_string_quotes_spaces_after_normalization(monkeypatch) -> None:
|
|
|
|
|
"""Paths with spaces must stay properly quoted after backslash normalization (#724)."""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
|
|
|
|
|
|
|
|
result = init_cli._command_string(
|
|
|
|
|
["C:\\Program Files\\headroom\\headroom.exe", "init", "hook", "ensure"]
|
|
|
|
|
)
|
|
|
|
|
assert "\\" not in result
|
|
|
|
|
assert '"C:/Program Files/headroom/headroom.exe"' in result
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_json_file_handles_missing_empty_and_non_mapping(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
missing = tmp_path / "missing.json"
|
|
|
|
|
empty = tmp_path / "empty.json"
|
|
|
|
|
array_payload = tmp_path / "payload.json"
|
|
|
|
|
empty.write_text(" \n", encoding="utf-8")
|
|
|
|
|
array_payload.write_text('["value"]\n', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
assert init_cli._json_file(missing) == {}
|
|
|
|
|
assert init_cli._json_file(empty) == {}
|
|
|
|
|
assert init_cli._json_file(array_payload) == {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_claude_hooks_rewrites_existing_entries(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
settings_path = tmp_path / "settings.json"
|
|
|
|
|
settings_path.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"env": {"KEEP": "1"},
|
|
|
|
|
"hooks": {
|
|
|
|
|
"SessionStart": [
|
|
|
|
|
"not-a-dict",
|
|
|
|
|
{"hooks": "not-a-list"},
|
|
|
|
|
{
|
|
|
|
|
"matcher": "startup|resume",
|
|
|
|
|
"hooks": [{"type": "command", "command": "echo keep-me"}],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"matcher": "startup|resume",
|
|
|
|
|
"hooks": [
|
|
|
|
|
{
|
|
|
|
|
"type": "command",
|
|
|
|
|
"command": "headroom init hook ensure --marker headroom-init-claude",
|
|
|
|
|
}
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_claude_hooks(settings_path, "init-local-demo", 9001)
|
|
|
|
|
|
|
|
|
|
payload = json.loads(settings_path.read_text(encoding="utf-8"))
|
fix(init): set ENABLE_TOOL_SEARCH=true so Claude Code keeps deferring tools (#746) (#995)
## Description
Claude Code disables on-demand tool loading (Tool Search) when
`ANTHROPIC_BASE_URL` is a custom host and `ENABLE_TOOL_SEARCH` is unset,
materializing all MCP/system tool schemas into its context window
(#746). With many MCP servers this overflows the window — breaking
sub-agent spawns ("prompt too long, ~214k > 200k") and forcing constant
compaction. `headroom wrap claude` already sets it; `init`/install did
not. Refs #746.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- Keep tool deferral on at both entry points, sharing one
`TOOL_SEARCH_ENV` / `TOOL_SEARCH_DEFAULT` constant from the Claude
provider package (`providers/claude/runtime.py`) so the key/default
can't drift:
- `init` (`_ensure_claude_hooks`): sets `ENABLE_TOOL_SEARCH=true` via
`setdefault`, respecting a pre-existing user-provided value.
- `install` (`build_install_env`): always writes
`ENABLE_TOOL_SEARCH=true`. This is the headroom-managed install env
(recorded and reverted on uninstall), so it is authoritative rather than
deferring to an existing value.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ pytest tests/test_cli/test_init_enable_tool_search.py -q
3 passed in 0.63s
```
## Real Behavior Proof
- Environment: Python 3.14, headroom proxy on :8799, ~19 MCP servers
connected
- Exact command / steps: launched `claude` through the proxy with vs
without `ENABLE_TOOL_SEARCH=true`, asked each to spawn 5 parallel
sub-agents
- Observed result: without it, all 5 sub-agents fail ("prompt too long,
~214k > 200k"); with `ENABLE_TOOL_SEARCH=true` all 5 succeed and traffic
compresses
- Not tested: non-Claude-Code agents
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 12:26:26 -04:00
|
|
|
assert payload["env"] == {
|
|
|
|
|
"KEEP": "1",
|
|
|
|
|
"ANTHROPIC_BASE_URL": "http://127.0.0.1:9001",
|
|
|
|
|
"ENABLE_TOOL_SEARCH": "true",
|
|
|
|
|
}
|
2026-04-23 15:55:12 -05:00
|
|
|
session_entries = payload["hooks"]["SessionStart"]
|
|
|
|
|
assert session_entries[0] == "not-a-dict"
|
|
|
|
|
assert session_entries[1] == {"hooks": "not-a-list"}
|
|
|
|
|
assert session_entries[2]["hooks"][0]["command"] == "echo keep-me"
|
|
|
|
|
assert session_entries[-1]["hooks"][0]["command"].endswith("--marker headroom-init-claude")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_copilot_hooks_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
config_path = tmp_path / "copilot.json"
|
|
|
|
|
config_path.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"hooks": {
|
|
|
|
|
"SessionStart": [
|
|
|
|
|
{"type": "command", "command": "echo keep"},
|
|
|
|
|
{
|
|
|
|
|
"type": "command",
|
|
|
|
|
"command": "headroom init hook ensure --marker headroom-init-copilot",
|
|
|
|
|
},
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_copilot_hooks(config_path, "init-user")
|
|
|
|
|
|
|
|
|
|
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
|
|
|
commands = [entry["command"] for entry in payload["hooks"]["SessionStart"]]
|
|
|
|
|
assert commands == ["echo keep", "headroom init hook ensure --marker headroom-init-copilot"]
|
|
|
|
|
|
|
|
|
|
|
fix(init/codex): merge into hooks.json instead of overwriting it (#2173)
## Description
`headroom init codex` destroys a user's existing Codex hooks.
`_ensure_codex_hooks` builds a fresh payload containing only Headroom's
two hooks and writes it wholesale:
```python
payload = { "hooks": { "SessionStart": [...headroom...], "PreToolUse": [...headroom...] } }
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
```
It never reads the existing file, so any user-managed hooks (and any
other top-level keys) in `~/.codex/hooks.json` are silently replaced
with just Headroom's entries — data loss on every `init codex` run.
The sibling registrars do it correctly: `_ensure_claude_hooks` and
`_ensure_copilot_hooks` both read via `_json_file`, then merge per event
and dedup on the Headroom marker, preserving unrelated user entries. The
codex path was the lone writer that overwrote.
## Fix
Read-merge-write, mirroring `_ensure_claude_hooks`: load the existing
payload, keep each event's entries that don't carry the
`headroom-init-codex` marker, append Headroom's, and write back. User
hooks and other top-level keys survive; Headroom's are deduped
(idempotent re-runs).
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
- `headroom/cli/init.py`: `_ensure_codex_hooks` reads via `_json_file`,
merges per event with marker dedup, and writes via `_write_json` (no
more wholesale overwrite).
- `tests/test_cli/test_init_cli.py`: add
`test_ensure_codex_hooks_preserves_user_hooks` — a user hook and an
unrelated top-level key survive; Headroom's hook is appended once.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ python -m py_compile headroom/cli/init.py tests/test_cli/test_init_cli.py
OK
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the merge with a
dependency-free script that replicates the old (overwrite) vs new
(merge) logic, and left the full pytest to CI.
- Exact command / steps: gave a config with a user `SessionStart` hook
(`echo my-own-hook`) and an unrelated top-level key (`notify: true`),
then ran both the old and new logic.
- Observed result: old drops both the user hook and the top-level key;
new keeps both and appends Headroom's hook exactly once. The new test
asserts this against the real `_ensure_codex_hooks`.
- Not tested: a live `headroom init codex` end to end; full local
`pytest` deferred to CI (OOM, per above).
## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change mirrors the existing `_ensure_claude_hooks`
merge logic, verified by the standalone proof and the new test (which
reuses the file's existing hooks-test harness).
2026-07-14 21:44:19 +05:30
|
|
|
def test_ensure_codex_hooks_preserves_user_hooks(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
"""init codex must merge into hooks.json, not overwrite it — a user's own
|
|
|
|
|
hooks (and unrelated top-level keys) must survive."""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "hooks.json"
|
|
|
|
|
path.write_text(
|
|
|
|
|
json.dumps(
|
|
|
|
|
{
|
|
|
|
|
"notify": True, # unrelated top-level key
|
|
|
|
|
"hooks": {
|
|
|
|
|
"SessionStart": [
|
|
|
|
|
{
|
|
|
|
|
"matcher": "startup",
|
|
|
|
|
"hooks": [{"type": "command", "command": "echo keep"}],
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
),
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_hook_command", lambda *parts: "headroom init hook ensure")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_hooks(path, "init-user")
|
|
|
|
|
|
|
|
|
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
# The unrelated top-level key survives.
|
|
|
|
|
assert payload["notify"] is True
|
|
|
|
|
# The user's own hook is retained and Headroom's is appended exactly once.
|
|
|
|
|
ss_commands = [
|
|
|
|
|
item["command"] for entry in payload["hooks"]["SessionStart"] for item in entry["hooks"]
|
|
|
|
|
]
|
|
|
|
|
assert "echo keep" in ss_commands
|
|
|
|
|
assert sum(1 for c in ss_commands if "headroom-init-codex" in c) == 1
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_replace_marker_block_replaces_existing_block(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
content = "before\n# start\nold\n# end\nafter\n"
|
|
|
|
|
|
|
|
|
|
replaced = init_cli._replace_marker_block(content, "# start", "# end", "# start\nnew\n# end")
|
|
|
|
|
|
|
|
|
|
assert replaced == "before\n\nafter\n\n# start\nnew\n# end\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_provider_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
f"prefix\n{init_cli._CODEX_PROVIDER_MARKER_START}\nold = true\n{init_cli._CODEX_PROVIDER_MARKER_END}\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 9100)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
assert content.count(init_cli._CODEX_PROVIDER_MARKER_START) == 1
|
|
|
|
|
assert 'base_url = "http://127.0.0.1:9100/v1"' in content
|
|
|
|
|
assert "old = true" not in content
|
2026-05-05 21:03:42 -05:00
|
|
|
assert 'env_key = "OPENAI_API_KEY"' not in content
|
2026-04-23 15:55:12 -05:00
|
|
|
|
|
|
|
|
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
def test_ensure_codex_provider_keeps_root_keys_above_existing_table(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
"""#260: a config ending in a table must not capture the provider root keys.
|
|
|
|
|
|
|
|
|
|
Appending the block after a trailing [features] table scoped model_provider
|
|
|
|
|
under it, so Codex refused to start with
|
|
|
|
|
'invalid type: string "headroom", expected a boolean in features'.
|
|
|
|
|
"""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
path.write_text("[features]\nhooks = true\n", encoding="utf-8")
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
# model_provider belongs at the document root, not under [features].
|
|
|
|
|
assert parsed["model_provider"] == "headroom"
|
|
|
|
|
assert "model_provider" not in parsed["features"]
|
|
|
|
|
assert "openai_base_url" not in parsed["features"]
|
|
|
|
|
# The user's existing table is preserved.
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
assert parsed["features"]["hooks"] is True
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
assert parsed["model_providers"]["headroom"]["base_url"] == "http://127.0.0.1:8787/v1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_provider_replaces_existing_model_provider(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A pre-existing root model_provider is replaced, never duplicated (#260).
|
|
|
|
|
|
|
|
|
|
A second top-level model_provider key would be invalid TOML; init owns it.
|
|
|
|
|
"""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
path.write_text('model_provider = "openai"\n[features]\nhooks = true\n', encoding="utf-8")
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8")) # raises on a duplicate key
|
|
|
|
|
assert parsed["model_provider"] == "headroom"
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
assert parsed["features"]["hooks"] is True
|
fix(codex): keep init model_provider at config root (#260)
`headroom init codex` appended its provider block to the end of
~/.codex/config.toml via _replace_marker_block. When the file ended in a
table (e.g. [features]), TOML scoped the block's root keys (model_provider,
openai_base_url) under that table, so Codex refused to start with:
invalid type: string "headroom", expected a boolean in features.
Add an at_root option to _replace_marker_block that inserts the block before
the first table header (reusing the module's line-based header detection), and
have _ensure_codex_provider use it. _ensure_codex_provider also strips any prior
top-level model_provider/openai_base_url assignment first, so init replaces an
existing value (or one an older version mis-scoped under a table) instead of
emitting a duplicate top-level key. Files with no tables still append, so the
keys stay at the root.
Add regression tests that parse the result with tomllib and assert
model_provider lands at the document root, not under [features], and that a
pre-existing model_provider is replaced rather than duplicated.
Verified end-to-end against the real codex CLI across 7 config shapes
(trailing [features], fresh, no-tables, root-key+table, other trailing table,
double-init, pre-existing provider): `codex doctor` reports "config could not
be loaded" before the fix and "config loaded / parse ok" after.
2026-05-30 19:33:28 -04:00
|
|
|
|
|
|
|
|
|
fix(init/codex): don't delete per-profile provider settings (#2146)
## Description
`headroom init codex` silently deletes a user's per-profile provider
settings.
`_ensure_codex_provider` owns the root-level `model_provider` /
`openai_base_url` keys, and to avoid emitting a duplicate top-level key
it strips any prior assignment before re-inserting its block:
```python
content = re.sub(r"(?m)^[ \t]*model_provider[ \t]*=.*\r?\n", "", content)
content = re.sub(r"(?m)^[ \t]*openai_base_url[ \t]*=.*\r?\n", "", content)
```
Those multiline regexes match the keys at any indentation, **in any TOML
table**. Codex supports per-profile overrides:
```toml
[profiles.work]
model_provider = "azure"
[profiles.gpt5]
model_provider = "openai"
```
So a user with named Codex profiles who runs `headroom init codex` has
every `[profiles.*]` `model_provider` / `openai_base_url` line silently
removed. Those profiles then fall through to the injected root
`model_provider = "headroom"` default — their routing is quietly
changed. That collateral deletion isn't needed to prevent the root-level
duplicate the strip exists for (#260); the unwrap-side sibling
`_strip_codex_init_block` proves the intent is precise (it only removes
the Headroom-owned value).
## Fix
Scope the strip to the document root — everything before the first table
header. Root-level `model_provider` / `openai_base_url` are still
replaced (init owns them), but keys inside `[profiles.*]` (or any other
table) are left untouched.
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
- `headroom/cli/init.py`: `_ensure_codex_provider` splits the config at
the first table header and strips `model_provider`/`openai_base_url`
only from the root section.
- `tests/test_cli/test_init_cli.py`: add
`test_ensure_codex_provider_preserves_profile_overrides` — a
`[profiles.work]` override survives init while the root key is replaced
by `headroom`.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [ ] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ python -m py_compile headroom/cli/init.py tests/test_cli/test_init_cli.py
OK
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17`. Importing
`headroom` pulls in the torch/transformers stack and a full `pytest`
gets OOM-killed on this box, so I verified the strip with a
dependency-free script that replicates the old (whole-file) vs new
(root-scoped) regex, and left the full pytest to CI.
- Exact command / steps: ran both strippers on a config with a root
`model_provider = "openai"` and a `[profiles.work]` block overriding
`model_provider`/`openai_base_url`.
- Observed result: the old strip deletes the `[profiles.work]` overrides
too; the new strip keeps them and still removes the root assignment. The
new test asserts the profile override survives and the root becomes
`headroom`.
- Not tested: a live `headroom init codex` end to end; full local
`pytest` deferred to CI (OOM, per above).
## 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
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" and "type checking" boxes are unchecked
because the full suite imports the ML stack, which I can't run in this
environment; the change scopes an existing regex strip to the document
root, verified by the standalone proof and the new test (the two
existing `_ensure_codex_provider` tests only exercise root-level and
block-placement behavior, both preserved). I kept the fix to
root-scoping rather than also matching only the `"headroom"` value,
since that preserves the #260 duplicate-key guard without the broad
deletion.
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
2026-07-14 21:44:10 +05:30
|
|
|
def test_ensure_codex_provider_preserves_profile_overrides(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
"""Per-profile model_provider/openai_base_url overrides must survive init.
|
|
|
|
|
|
|
|
|
|
init owns the ROOT-level keys, but the same keys inside [profiles.*] are the
|
|
|
|
|
user's per-profile routing; a broad strip silently reroutes those profiles
|
|
|
|
|
to the injected "headroom" default (config corruption).
|
|
|
|
|
"""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
'model_provider = "openai"\n\n'
|
|
|
|
|
"[profiles.work]\n"
|
|
|
|
|
'model_provider = "azure"\n'
|
|
|
|
|
'openai_base_url = "https://azure.example/v1"\n',
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
# Root is replaced by headroom (no duplicate top-level key).
|
|
|
|
|
assert parsed["model_provider"] == "headroom"
|
|
|
|
|
# The user's per-profile overrides are untouched.
|
|
|
|
|
assert parsed["profiles"]["work"]["model_provider"] == "azure"
|
|
|
|
|
assert parsed["profiles"]["work"]["openai_base_url"] == "https://azure.example/v1"
|
|
|
|
|
|
|
|
|
|
|
fix(codex): poll /wham/usage for subscription limits (handshake no longer sends x-codex-* headers) (#924)
## Description
Codex's subscription usage window (primary/secondary rate-limit gauges)
stopped populating for ChatGPT-OAuth sessions. This PR restores it by
polling Codex's dedicated usage endpoint instead of relying on response
headers that are no longer sent.
### Why the previous approach no longer works
The existing code populates `CodexRateLimitState` from `x-codex-*`
rate-limit headers captured on the `/v1/responses` WebSocket handshake
(`update_from_headers` at WS accept). That worked when OpenAI returned
`x-codex-primary-used-percent`, `x-codex-primary-window-minutes`, etc.
on the handshake response.
OpenAI has since stopped sending those headers on the ChatGPT WebSocket
handshake. I confirmed this by faithfully replaying a real Plus-account
handshake (both `prewarm` and regular `request_kind`): no `x-codex-*`
headers come back on either. This matches OpenAI's own move to a
dedicated usage endpoint (`GET /backend-api/codex/usage` in CodexApi
mode) and reports such as openai/codex#14728. So `update_from_headers`
now runs on every accept but finds nothing to parse, and the window
silently stays empty.
The headers aren't coming back, so there is nothing to fix in the
parsing path. The data now lives behind a request we have to make
ourselves.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
## Changes Made
- `subscription/codex_rate_limits.py`:
- `parse_codex_usage_payload()` / `update_from_usage_payload()` — map
the `GET /backend-api/wham/usage` JSON (`rate_limit.primary_window` /
`secondary_window` with `used_percent`, `limit_window_seconds`,
`reset_at`; `credits`; `rate_limit_reached_type`) into the existing
`CodexRateLimitState`. `limit_window_seconds` is converted to
window-minutes with the same round-up codex-rs uses (`(secs + 59) //
60`).
- `maybe_schedule_usage_poll()` — fire-and-forget, throttled to one
request per 60s, scoped to ChatGPT sessions (requires both a Bearer
token and `ChatGPT-Account-Id`; API-key traffic is skipped). Uses an
in-flight guard so concurrent accepts don't stack polls. Endpoint is
overridable via `HEADROOM_CODEX_USAGE_URL`.
- `proxy/handlers/openai.py`:
- At the Codex WS accept site, after the now-usually-empty
`update_from_headers` block, schedule the usage poll. Wrapped in
`contextlib.suppress` and fully non-blocking so it can never delay or
fail the WebSocket accept.
The old header-capture path is intentionally left in place as a no-cost
fallback in case OpenAI restores the headers.
## 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 (live `/wham/usage` replay against a Plus
account returned HTTP 200 with the expected schema; payload fixture in
tests mirrors that real shape)
## Test Output
```
$ uv run pytest tests/test_codex_rate_limits.py -q
........................................ [100%]
41 passed in 0.16s
$ uv run ruff check headroom/subscription/codex_rate_limits.py headroom/proxy/handlers/openai.py tests/test_codex_rate_limits.py
All checks passed!
$ uv run mypy headroom/subscription/codex_rate_limits.py
Success: no issues found in 1 source file
```
## Additional Notes
- New tests cover: full-payload mapping, window-minutes round-up,
credits balance kept only when `has_credits`, promo object vs string,
empty payload returns `None`, missing `used_percent` skipped,
header-gating (requires Bearer + account-id), poll throttling, and
no-event-loop safety.
- Scoping to `ChatGPT-Account-Id` keeps the poll off API-key traffic,
and the 60s throttle plus in-flight guard bound it to at most one
lightweight GET per minute per running proxy.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 00:03:14 +02:00
|
|
|
def test_ensure_codex_provider_emits_requires_openai_auth_for_chatgpt(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
(tmp_path / "auth.json").write_text('{"auth_mode": "chatgpt"}', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
assert "requires_openai_auth = true" in path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_provider_omits_requires_openai_auth_for_api_key(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
(tmp_path / "auth.json").write_text('{"auth_mode": "apikey"}', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
assert "requires_openai_auth" not in path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_ensure_codex_feature_flag_replaces_existing_marker(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
f"[features]\n{init_cli._CODEX_FEATURE_MARKER_START}\ncodex_hooks = false\n{init_cli._CODEX_FEATURE_MARKER_END}\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
assert "hooks = true" in content
|
|
|
|
|
assert "codex_hooks" not in content
|
2026-04-23 15:55:12 -05:00
|
|
|
|
|
|
|
|
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
def test_ensure_codex_feature_flag_replaces_marker_inside_features_scope(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
"[features]\n"
|
|
|
|
|
f"{init_cli._CODEX_FEATURE_MARKER_START}\n"
|
|
|
|
|
"hooks = true\n"
|
|
|
|
|
f"{init_cli._CODEX_FEATURE_MARKER_END}\n"
|
|
|
|
|
"\n[tools]\nhooks = false\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["tools"]["hooks"] is False
|
|
|
|
|
assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
2026-04-23 15:55:12 -05:00
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("[features]\ncodex_hooks = true\nshell_tool = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["features"]["shell_tool"] is True
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
assert content.count("hooks = true") == 1
|
|
|
|
|
assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_migrates_dotted_legacy_codex_hooks_key(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("features.codex_hooks = true\nfeatures.shell_tool = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["features"]["shell_tool"] is True
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
assert "features.hooks = true" in content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_migrates_when_both_keys_present(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
# A config that carried both the legacy and the correct key must not produce
|
|
|
|
|
# a duplicate `hooks` key (which Codex would reject as invalid TOML).
|
|
|
|
|
path.write_text("[features]\ncodex_hooks = true\nhooks = false\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
# The user's explicit `hooks` value is respected; only the legacy key is removed.
|
|
|
|
|
assert parsed["features"]["hooks"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_migrates_when_keys_reversed(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("[features]\nhooks = false\ncodex_hooks = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
assert parsed["features"]["hooks"] is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_ignores_hooks_outside_features(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
"[features]\nshell_tool = true\n\n[some_other_table]\nhooks = true\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["features"]["shell_tool"] is True
|
|
|
|
|
assert parsed["some_other_table"]["hooks"] is True
|
|
|
|
|
assert content.count(init_cli._CODEX_FEATURE_MARKER_START) == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_ignores_hooks_after_commented_table_header(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
"[features]\nshell_tool = true\n\n[some_other_table] # comment\nhooks = true\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["features"]["shell_tool"] is True
|
|
|
|
|
assert parsed["some_other_table"]["hooks"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_respects_commented_features_header_and_quoted_key(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text('[features] # comment\n"hooks" = false\n', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is False
|
|
|
|
|
assert init_cli._CODEX_FEATURE_MARKER_START not in content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_migrates_quoted_legacy_codex_hooks_key(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text('[features]\n"codex_hooks" = true\n', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_respects_root_dotted_feature_key(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("features.hooks = false\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert parsed["features"]["hooks"] is False
|
2026-04-23 15:55:12 -05:00
|
|
|
assert init_cli._CODEX_FEATURE_MARKER_START not in content
|
|
|
|
|
|
|
|
|
|
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
def test_ensure_codex_feature_flag_preserves_legacy_key_outside_features(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("[some_other_table]\ncodex_hooks = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
parsed = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert parsed["some_other_table"]["codex_hooks"] is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_drops_legacy_key_outside_marker(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text(
|
|
|
|
|
"[features]\n"
|
|
|
|
|
"codex_hooks = true\n"
|
|
|
|
|
f"{init_cli._CODEX_FEATURE_MARKER_START}\n"
|
|
|
|
|
"hooks = true\n"
|
|
|
|
|
f"{init_cli._CODEX_FEATURE_MARKER_END}\n",
|
|
|
|
|
encoding="utf-8",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
parsed = tomllib.loads(content)
|
|
|
|
|
assert "codex_hooks" not in parsed["features"]
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert content.count("hooks = true") == 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_codex_feature_flag_is_idempotent(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text("[features]\ncodex_hooks = true\n", encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
first = path.read_text(encoding="utf-8")
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
second = path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
assert first == second
|
|
|
|
|
parsed = tomllib.loads(second)
|
|
|
|
|
assert parsed["features"]["hooks"] is True
|
|
|
|
|
assert second.count("hooks = true") == 1
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_ensure_codex_feature_flag_creates_features_section_when_missing(
|
|
|
|
|
monkeypatch, tmp_path: Path
|
|
|
|
|
) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text('model = "gpt-5"\n', encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_feature_flag(path)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
assert "[features]" in content
|
fix(codex): write canonical hooks feature flag and migrate deprecated codex_hooks (#743)
## Description
`headroom init codex` writes the hooks feature flag into
`.codex/config.toml`
under the key `codex_hooks`. Codex renamed the canonical key to `hooks`
and kept
`codex_hooks` as a legacy alias (openai/codex#20522). Current Codex
builds warn
about `[features].codex_hooks` and tell users to use `[features].hooks`
instead,
so configs written by headroom should stop emitting the deprecated key.
This PR switches headroom to write the canonical `hooks` key and
**migrates
existing configs in place**. The migration is the tricky part: a config
can
already contain `codex_hooks`, `hooks`, or both, in any order, inside or
outside
headroom's marker block — and a naive replace can emit a *duplicate*
`hooks`
key, which is invalid TOML that Codex rejects outright.
The fix strips every `codex_hooks` line up front (any value, anywhere) —
mirroring the existing top-level key cleanup in `_ensure_codex_provider`
(#260)
— then guarantees `hooks` is present without ever duplicating it, and
respects a
user-managed `hooks` value that lives outside our marker block.
Fixes: N/A (no tracking issue — surfaced while aligning with Codex >=
0.129;
related upstream context: openai/codex#20522 and the warning behavior
discussed
in openai/codex#22148)
## 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
- Write the canonical `hooks` key instead of the deprecated
`codex_hooks` in
`_ensure_codex_feature_flag` (`headroom/cli/init.py`).
- Strip any `codex_hooks` line (any value, inside or outside the marker
block)
before ensuring the flag, so re-running `init` migrates a legacy config
instead
of leaving a stale key or producing a duplicate `hooks` key (invalid
TOML).
- Respect a user-managed `hooks` value found outside headroom's marker
block
(e.g. `hooks = false`); only the deprecated alias is removed.
- Make the insert/create paths match `_replace_marker_block`'s
normalisation so
re-running `init` is byte-idempotent.
- Extract a `_codex_feature_block()` helper to remove the 4x duplicated
marker
block assembly.
- Add regression tests for the previously-broken edge cases.
## Testing
- [x] Unit tests pass (`pytest`) — affected module fully green (see
output)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom/cli/init.py`)
- [x] New tests added for new functionality
- [x] Manual testing performed (reproduced each edge case against the
patched
function via `tomllib.loads`)
## Test Output
```
$ pytest -v tests/test_cli/test_init_cli.py -k "feature_flag or hooks_feature"
tests/test_cli/test_init_cli.py::test_init_codex_merges_feature_flag_into_existing_table PASSED
tests/test_cli/test_init_cli.py::test_init_codex_creates_hooks_feature_flag_on_first_init PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_replaces_existing_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_legacy_codex_hooks_key PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_both_keys_present PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_migrates_when_keys_reversed PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_drops_legacy_key_outside_marker PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_is_idempotent PASSED
tests/test_cli/test_init_cli.py::test_ensure_codex_feature_flag_creates_features_section_when_missing PASSED
======================= 9 passed, 45 deselected in 0.24s =======================
$ pytest -q tests/test_cli/test_init_cli.py
54 passed
$ ruff check .
All checks passed!
$ mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
Note: the broader `tests/test_cli/` run has one unrelated failure
(`test_wrap_copilot_auto_detects_running_proxy_backend`) caused by a
real proxy
already bound to port 8787 in the local environment — it fails
identically on a
clean checkout without this change.
## 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 (none
required)
- [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 (managed by
release-please;
generated from the conventional commit, not edited by hand)
## Screenshots (if applicable)
N/A
## Additional Notes
- **Why the duplicate-key path matters:** TOML forbids duplicate keys,
so a
`[features]` table containing both `codex_hooks` and `hooks` (which the
old
in-place migration could produce) makes Codex reject `config.toml`
entirely.
The new "strip then ensure" approach can never emit two `hooks` lines.
- **Version provenance:** the `codex_hooks` -> `hooks` rename landed in
openai/codex#20522, first shipped in Codex `rust-v0.129.0`.
`codex_hooks`
remains a working legacy alias, but current Codex builds can warn users
to
move to `[features].hooks`.
- **Idempotency:** running `headroom init codex` repeatedly now produces
a
byte-stable `config.toml`, so there is no churn on re-init.
2026-06-13 02:49:34 +09:00
|
|
|
assert "hooks = true" in content
|
2026-04-23 15:55:12 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_manifest_changed_detects_differences(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
existing = SimpleNamespace(
|
|
|
|
|
port=8787,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert not init_cli._manifest_changed(
|
|
|
|
|
existing,
|
|
|
|
|
port=8787,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory=False,
|
|
|
|
|
)
|
|
|
|
|
assert init_cli._manifest_changed(
|
|
|
|
|
existing,
|
|
|
|
|
port=9000,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_runtime_manifest_merges_targets_and_stops_changed_runtime(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
existing = SimpleNamespace(
|
|
|
|
|
targets=["claude"],
|
|
|
|
|
mutations=["mutation"],
|
|
|
|
|
port=8787,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
saved: list[object] = []
|
|
|
|
|
stopped: list[object] = []
|
|
|
|
|
built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"build_manifest",
|
|
|
|
|
lambda **kwargs: built.__dict__.update(kwargs) or built,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
|
|
|
|
|
monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stopped.append(manifest))
|
|
|
|
|
|
|
|
|
|
profile = init_cli._ensure_runtime_manifest(
|
|
|
|
|
global_scope=True,
|
|
|
|
|
targets=["codex"],
|
|
|
|
|
port=9001,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert profile == "init-user"
|
|
|
|
|
assert stopped == [existing]
|
|
|
|
|
assert saved == [built]
|
|
|
|
|
assert built.targets == ["claude", "codex"]
|
|
|
|
|
assert built.mutations == ["mutation"]
|
|
|
|
|
assert built.supervisor_kind == init_cli.SupervisorKind.NONE.value
|
|
|
|
|
assert built.artifacts == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_runtime_manifest_ignores_stop_runtime_errors(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
existing = SimpleNamespace(
|
|
|
|
|
targets=[],
|
|
|
|
|
mutations=[],
|
|
|
|
|
port=8787,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory_enabled=False,
|
|
|
|
|
)
|
|
|
|
|
saved: list[object] = []
|
|
|
|
|
built = SimpleNamespace(supervisor_kind="", artifacts=[], mutations=[], targets=[])
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "_runtime_profile", lambda global_scope, cwd=None: "init-user")
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: existing)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"build_manifest",
|
|
|
|
|
lambda **kwargs: built.__dict__.update(kwargs) or built,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "save_manifest", lambda manifest: saved.append(manifest))
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "stop_runtime", lambda manifest: (_ for _ in ()).throw(RuntimeError("boom"))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_runtime_manifest(
|
|
|
|
|
global_scope=True,
|
|
|
|
|
targets=["claude"],
|
|
|
|
|
port=9001,
|
|
|
|
|
backend="anthropic",
|
|
|
|
|
anyllm_provider=None,
|
|
|
|
|
region=None,
|
|
|
|
|
memory=False,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert saved == [built]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_apply_user_env_routes_by_platform(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
manifest = SimpleNamespace(base_env={"OLD": "1"}, tool_envs={})
|
|
|
|
|
windows_calls: list[object] = []
|
|
|
|
|
unix_calls: list[object] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "_env_manifest", lambda values: manifest)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_apply_windows_env_scope", lambda value: windows_calls.append(value)
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_apply_unix_env_scope", lambda value: unix_calls.append(value))
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
|
|
|
init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "openai"})
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="posix"))
|
|
|
|
|
init_cli._apply_user_env({"COPILOT_PROVIDER_TYPE": "anthropic"})
|
|
|
|
|
|
|
|
|
|
assert manifest.base_env == {}
|
|
|
|
|
assert manifest.tool_envs == {"copilot": {"COPILOT_PROVIDER_TYPE": "anthropic"}}
|
|
|
|
|
assert windows_calls == [manifest]
|
|
|
|
|
assert unix_calls == [manifest]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_resolve_copilot_env_supports_anthropic(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
assert init_cli._resolve_copilot_env(9010, "anthropic") == {
|
|
|
|
|
"COPILOT_PROVIDER_TYPE": "anthropic",
|
|
|
|
|
"COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:9010",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_marketplace_source_prefers_repo_checkout(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.delenv("HEADROOM_MARKETPLACE_SOURCE", raising=False)
|
|
|
|
|
|
|
|
|
|
assert init_cli._marketplace_source() == str(Path(init_cli.__file__).resolve().parents[2])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_checked_raises_on_failure(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
class _Result:
|
|
|
|
|
returncode = 2
|
|
|
|
|
stderr = "bad stderr"
|
|
|
|
|
stdout = "bad stdout"
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda *args, **kwargs: _Result())
|
|
|
|
|
|
|
|
|
|
with pytest.raises(
|
|
|
|
|
click.ClickException, match="claude plugin install failed: bad stderr\nbad stdout"
|
|
|
|
|
):
|
|
|
|
|
init_cli._run_checked(["claude", "plugin", "install"], action="claude plugin install")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_claude_marketplace_errors_without_binary(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(click.ClickException, match="'claude' not found"):
|
|
|
|
|
init_cli._install_claude_marketplace("local")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_claude_marketplace_runs_expected_commands(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
calls: list[tuple[list[str], str]] = []
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: "claude")
|
|
|
|
|
monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_run_checked", lambda command, action: calls.append((command, action))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._install_claude_marketplace("user")
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
(["claude", "plugin", "marketplace", "add", "repo/source"], "claude marketplace add"),
|
|
|
|
|
(
|
|
|
|
|
["claude", "plugin", "install", "headroom@headroom-marketplace", "--scope", "user"],
|
|
|
|
|
"claude plugin install",
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_copilot_marketplace_handles_missing_binary(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: None)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(click.ClickException, match="'copilot' not found"):
|
|
|
|
|
init_cli._install_copilot_marketplace()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_install_copilot_marketplace_runs_expected_commands(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
calls: list[tuple[list[str], str]] = []
|
|
|
|
|
monkeypatch.setattr(init_cli.shutil, "which", lambda name: "copilot")
|
|
|
|
|
monkeypatch.setattr(init_cli, "_marketplace_source", lambda: "repo/source")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_run_checked", lambda command, action: calls.append((command, action))
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._install_copilot_marketplace()
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
(["copilot", "plugin", "marketplace", "add", "repo/source"], "copilot marketplace add"),
|
|
|
|
|
(
|
|
|
|
|
["copilot", "plugin", "install", "headroom@headroom-marketplace"],
|
|
|
|
|
"copilot plugin install",
|
|
|
|
|
),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_profile_running_covers_runtime_modes(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
docker_manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_DOCKER.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
|
|
|
profile="docker-profile",
|
|
|
|
|
)
|
|
|
|
|
service_manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.SERVICE.value,
|
|
|
|
|
profile="service-profile",
|
|
|
|
|
)
|
|
|
|
|
task_manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
|
|
|
profile="task-profile",
|
|
|
|
|
)
|
|
|
|
|
manifests = {
|
|
|
|
|
"docker-profile": docker_manifest,
|
|
|
|
|
"service-profile": service_manifest,
|
|
|
|
|
"task-profile": task_manifest,
|
|
|
|
|
}
|
|
|
|
|
docker_calls: list[object] = []
|
|
|
|
|
service_calls: list[object] = []
|
|
|
|
|
detached_calls: list[str] = []
|
|
|
|
|
wait_calls: list[tuple[str, int]] = []
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifests.get(profile))
|
2026-06-11 10:34:43 +09:00
|
|
|
monkeypatch.setattr(init_cli, "runtime_status", lambda manifest: "stopped")
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def fake_start_lock(profile: str):
|
|
|
|
|
yield True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "acquire_runtime_start_lock", fake_start_lock)
|
2026-04-23 15:55:12 -05:00
|
|
|
|
|
|
|
|
def fake_wait_ready(manifest, timeout_seconds: int) -> bool:
|
|
|
|
|
wait_calls.append((manifest.profile, timeout_seconds))
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "start_persistent_docker", lambda manifest: docker_calls.append(manifest)
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "start_supervisor", lambda manifest: service_calls.append(manifest)
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"start_detached_agent",
|
|
|
|
|
lambda profile: detached_calls.append(profile),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_profile_running("missing")
|
|
|
|
|
init_cli._ensure_profile_running("docker-profile")
|
|
|
|
|
init_cli._ensure_profile_running("service-profile")
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
|
|
|
|
|
assert docker_calls == [docker_manifest]
|
|
|
|
|
assert service_calls == [service_manifest]
|
|
|
|
|
assert detached_calls == ["task-profile"]
|
|
|
|
|
assert ("docker-profile", 1) in wait_calls
|
|
|
|
|
assert ("docker-profile", 45) in wait_calls
|
|
|
|
|
|
|
|
|
|
|
2026-06-12 07:59:31 +08:00
|
|
|
def test_ensure_profile_running_suppresses_hook_recovery_output(monkeypatch, capfd) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.SERVICE.value,
|
|
|
|
|
profile="service-profile",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
|
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False)
|
|
|
|
|
|
|
|
|
|
def noisy_start_supervisor(manifest) -> None:
|
|
|
|
|
print("python stdout")
|
|
|
|
|
print("python stderr", file=sys.stderr)
|
|
|
|
|
os.write(1, b"fd stdout\n")
|
|
|
|
|
os.write(2, b"fd stderr\n")
|
|
|
|
|
raise RuntimeError("not permitted")
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "start_supervisor", noisy_start_supervisor)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_profile_running("service-profile")
|
|
|
|
|
|
|
|
|
|
captured = capfd.readouterr()
|
|
|
|
|
assert captured.out == ""
|
|
|
|
|
assert captured.err == ""
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_ensure_profile_running_returns_when_ready_or_on_exception(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
|
|
|
profile="task-profile",
|
|
|
|
|
)
|
|
|
|
|
detached_calls: list[str] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
|
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: True)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"start_detached_agent",
|
|
|
|
|
lambda profile: detached_calls.append(profile),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
assert detached_calls == []
|
|
|
|
|
|
2026-06-11 10:34:43 +09:00
|
|
|
@contextmanager
|
|
|
|
|
def fake_start_lock(profile: str):
|
|
|
|
|
yield True
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "acquire_runtime_start_lock", fake_start_lock)
|
|
|
|
|
monkeypatch.setattr(init_cli, "runtime_status", lambda manifest: "stopped")
|
2026-04-23 15:55:12 -05:00
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"start_detached_agent",
|
|
|
|
|
lambda profile: (_ for _ in ()).throw(RuntimeError("boom")),
|
|
|
|
|
)
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
|
|
|
|
|
|
2026-06-11 10:34:43 +09:00
|
|
|
def test_ensure_profile_running_skips_spawn_when_start_lock_is_held(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
|
|
|
profile="task-profile",
|
|
|
|
|
)
|
|
|
|
|
detached_calls: list[str] = []
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def fake_start_lock(profile: str):
|
|
|
|
|
yield False
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
|
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", lambda manifest, timeout_seconds: False)
|
|
|
|
|
monkeypatch.setattr(init_cli, "acquire_runtime_start_lock", fake_start_lock)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"start_detached_agent",
|
|
|
|
|
lambda profile: detached_calls.append(profile),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
|
|
|
|
|
assert detached_calls == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_ensure_profile_running_does_not_spawn_again_during_slow_startup(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
manifest = SimpleNamespace(
|
|
|
|
|
preset=init_cli.InstallPreset.PERSISTENT_TASK.value,
|
|
|
|
|
supervisor_kind=init_cli.SupervisorKind.NONE.value,
|
|
|
|
|
profile="task-profile",
|
|
|
|
|
)
|
|
|
|
|
detached_calls: list[str] = []
|
|
|
|
|
wait_calls: list[int] = []
|
|
|
|
|
stop_calls: list[object] = []
|
|
|
|
|
|
|
|
|
|
@contextmanager
|
|
|
|
|
def fake_start_lock(profile: str):
|
|
|
|
|
yield True
|
|
|
|
|
|
|
|
|
|
def fake_wait_ready(manifest, timeout_seconds: int) -> bool:
|
|
|
|
|
wait_calls.append(timeout_seconds)
|
|
|
|
|
return bool(detached_calls and timeout_seconds == init_cli._STARTUP_READY_TIMEOUT_SECONDS)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "load_manifest", lambda profile: manifest)
|
|
|
|
|
monkeypatch.setattr(init_cli, "wait_ready", fake_wait_ready)
|
|
|
|
|
monkeypatch.setattr(init_cli, "acquire_runtime_start_lock", fake_start_lock)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"runtime_status",
|
|
|
|
|
lambda manifest: "running" if detached_calls else "stopped",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"start_detached_agent",
|
|
|
|
|
lambda profile: detached_calls.append(profile),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(init_cli, "stop_runtime", lambda manifest: stop_calls.append(manifest))
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
init_cli._ensure_profile_running("task-profile")
|
|
|
|
|
|
|
|
|
|
assert detached_calls == ["task-profile"]
|
|
|
|
|
assert init_cli._STARTUP_READY_TIMEOUT_SECONDS in wait_calls
|
|
|
|
|
assert stop_calls == []
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 15:55:12 -05:00
|
|
|
def test_init_codex_windows_warns_about_upstream_hook_limitation(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
messages: list[str] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "os", SimpleNamespace(name="nt"))
|
|
|
|
|
monkeypatch.setattr(init_cli, "_codex_scope_path", lambda global_scope: Path("config.toml"))
|
|
|
|
|
monkeypatch.setattr(init_cli, "_codex_hooks_path", lambda global_scope: Path("hooks.json"))
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_provider", lambda path, port: None)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_feature_flag", lambda path: None)
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_codex_hooks", lambda path, profile: None)
|
|
|
|
|
monkeypatch.setattr(init_cli.click, "echo", lambda message: messages.append(message))
|
|
|
|
|
|
|
|
|
|
init_cli._init_codex(global_scope=True, profile="init-user", port=9000)
|
|
|
|
|
|
|
|
|
|
assert any("disabled upstream on Windows" in message for message in messages)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_openclaw_propagates_nonzero_exit(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
class _Result:
|
|
|
|
|
returncode = 9
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(init_cli, "resolve_headroom_command", lambda: ["headroom"])
|
|
|
|
|
monkeypatch.setattr(init_cli.subprocess, "run", lambda command: _Result())
|
|
|
|
|
|
|
|
|
|
with pytest.raises(SystemExit) as exc:
|
|
|
|
|
init_cli._init_openclaw(global_scope=True, port=9999)
|
|
|
|
|
|
|
|
|
|
assert exc.value.code == 9
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_run_init_targets_dispatches_supported_targets(monkeypatch) -> None:
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
calls: list[tuple[str, tuple[object, ...]]] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "_ensure_runtime_manifest", lambda **kwargs: "init-profile")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"_init_claude",
|
|
|
|
|
lambda **kwargs: calls.append(
|
|
|
|
|
("claude", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"_init_copilot",
|
|
|
|
|
lambda **kwargs: calls.append(
|
|
|
|
|
("copilot", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"_init_codex",
|
|
|
|
|
lambda **kwargs: calls.append(
|
|
|
|
|
("codex", (kwargs["global_scope"], kwargs["profile"], kwargs["port"]))
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"_init_openclaw",
|
|
|
|
|
lambda **kwargs: calls.append(("openclaw", (kwargs["global_scope"], kwargs["port"]))),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
init_cli._run_init_targets(
|
|
|
|
|
targets=["claude", "copilot", "codex", "openclaw"],
|
|
|
|
|
global_scope=True,
|
|
|
|
|
port=9000,
|
|
|
|
|
backend="openai",
|
|
|
|
|
anyllm_provider="provider",
|
|
|
|
|
region="us-east-1",
|
|
|
|
|
memory=True,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert calls == [
|
|
|
|
|
("claude", (True, "init-profile", 9000)),
|
|
|
|
|
("copilot", (True, "init-profile", 9000)),
|
|
|
|
|
("codex", (True, "init-profile", 9000)),
|
|
|
|
|
("openclaw", (True, 9000)),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_subcommand_uses_group_options(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
|
monkeypatch.setattr(init_cli, "_run_init_targets", lambda **kwargs: captured.update(kwargs))
|
|
|
|
|
|
|
|
|
|
result = runner.invoke(
|
|
|
|
|
fake_main,
|
|
|
|
|
["init", "-g", "--port", "9007", "--backend", "openai", "--memory", "claude"],
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert captured == {
|
|
|
|
|
"targets": ["claude"],
|
|
|
|
|
"global_scope": True,
|
|
|
|
|
"port": 9007,
|
|
|
|
|
"backend": "openai",
|
|
|
|
|
"anyllm_provider": None,
|
|
|
|
|
"region": None,
|
|
|
|
|
"memory": True,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_hook_ensure_prefers_global_when_local_missing(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
ensured: list[str] = []
|
|
|
|
|
monkeypatch.setattr(init_cli, "_local_profile", lambda cwd=None: "init-repo-12345678")
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli,
|
|
|
|
|
"load_manifest",
|
|
|
|
|
lambda profile: object() if profile == init_cli._GLOBAL_PROFILE else None,
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert ensured == [init_cli._GLOBAL_PROFILE]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_hook_ensure_uses_explicit_profile(monkeypatch) -> None:
|
|
|
|
|
init_cli, fake_main = _load_init_module(monkeypatch)
|
|
|
|
|
ensured: list[str] = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
init_cli, "_ensure_profile_running", lambda profile: ensured.append(profile)
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
runner = CliRunner()
|
|
|
|
|
result = runner.invoke(fake_main, ["init", "hook", "ensure", "--profile", "init-explicit"])
|
|
|
|
|
|
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
assert ensured == ["init-explicit"]
|
2026-05-06 10:56:27 -05:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Bug 3 (#406): _ensure_codex_provider must inject openai_base_url
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_init_codex_writes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
"""_ensure_codex_provider must write openai_base_url at the top level so that
|
|
|
|
|
subscription (ChatGPT plan) users are routed through headroom even when the
|
|
|
|
|
init entry point is used instead of wrap."""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
path = tmp_path / ".codex" / "config.toml"
|
|
|
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
|
|
|
|
|
content = path.read_text(encoding="utf-8")
|
|
|
|
|
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content, (
|
|
|
|
|
f"openai_base_url missing from init codex config:\n{content}"
|
|
|
|
|
)
|
|
|
|
|
# Must NOT appear inside a [section] block.
|
|
|
|
|
lines = content.splitlines()
|
|
|
|
|
in_section = False
|
|
|
|
|
for line in lines:
|
|
|
|
|
stripped = line.strip()
|
|
|
|
|
if stripped.startswith("["):
|
|
|
|
|
in_section = True
|
|
|
|
|
if in_section and stripped.startswith("openai_base_url"):
|
|
|
|
|
raise AssertionError(
|
|
|
|
|
f"openai_base_url appeared inside a section block in init output:\n{content}"
|
|
|
|
|
)
|
|
|
|
|
# Bug 3 regression guard.
|
|
|
|
|
assert "requires_openai_auth" not in content, (
|
|
|
|
|
f"requires_openai_auth must not appear in init codex config:\n{content}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
fix(codex): retag threads on init so Codex Desktop history stays visible (#961) (#1349)
## Description
Installing Headroom for Codex via `headroom init` can make Codex Desktop
appear to lose its local chat/thread history. The data is never deleted
— Codex filters its sidebar/search by the active `model_provider`, and
the init path set `model_provider = "headroom"` without retagging
existing threads, so native `openai` threads disappeared from the menu.
The install (`headroom.providers.codex.install`) and wrap
(`headroom.cli.wrap`) paths already reconcile thread provider tags
across the proxy boundary (retag `openai -> headroom` on enable). The
init path — `_ensure_codex_provider` in `headroom/cli/init.py`, which is
exactly what the issue reproduces ("Headroom init proxy" provider,
`headroom-init-codex` hook) — was the one place that injected the
provider without retagging. This wires the same reconciliation into the
init path. The revert direction is already handled by `headroom unwrap
codex`.
Closes #961
## 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
- `headroom/cli/init.py`: `_ensure_codex_provider` now calls
`retag_to_headroom(path.parent)` after writing the init provider block,
so existing native threads stay visible under the active `headroom`
provider. Third-party providers (e.g. `anthropic`) are left untouched
(existing `retag_thread_providers` behaviour).
- `tests/test_cli/test_init_cli.py`: regression test seeding a Codex
Desktop `state_5.sqlite` and asserting `_ensure_codex_provider` retags
`openai -> headroom` while leaving other providers alone.
- `CHANGELOG.md`: Unreleased → Bug Fixes entry.
## 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
$ uv run pytest tests/test_cli/test_init_cli.py tests/test_provider_codex_threads.py tests/test_provider_codex_install.py -q
84 passed in 0.84s
$ uv run ruff check headroom/cli/init.py tests/test_cli/test_init_cli.py
All checks passed!
$ uv run mypy headroom/cli/init.py
Success: no issues found in 1 source file
```
#### RED → GREEN proof
RED — new test with the prod fix (the `retag_to_headroom` call)
reverted:
```text
E AssertionError: existing openai threads not retagged: {'anthropic': 1, 'openai': 2}
1 failed in 0.44s
```
GREEN — with the fix applied:
```text
tests/test_cli/test_init_cli.py::test_init_codex_provider_retags_existing_threads
1 passed in 0.36s
```
## Real Behavior Proof
- Environment: Linux, Python 3.13, headroom @ this branch.
- Exact command / steps: seed a Codex Desktop store
(`<codex_home>/sqlite/state_5.sqlite`) with native threads, then run the
init provider injection:
```text
before init: {'anthropic': 1, 'openai': 2}
after init: {'anthropic': 1, 'headroom': 2}
config model_provider line: ['model_provider = "headroom"',
'[model_providers.headroom]']
```
- Observed result: after init, the two `openai` threads are retagged to
`headroom` (so they stay visible under the now-active provider), while
the `anthropic` thread is left untouched. Before the fix they stayed
`openai` and were filtered out of Codex Desktop's menu.
- Not tested: the live Codex Desktop GUI itself (proprietary, no
sandbox); the filtering behaviour is the documented `thread/list`
provider filter described in the issue, and the store-level retag that
makes history visible is covered above and by the unit test.
## 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 have updated the CHANGELOG.md if applicable
## Additional Notes
Scope is limited to the init provider path; the install and wrap paths
already perform this reconciliation. Screenshots N/A (no UI change on
Headroom's side).
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: JD Davis <mxjerrett@gmail.com>
2026-06-24 17:14:40 +02:00
|
|
|
def test_init_codex_provider_retags_existing_threads(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
"""`headroom init` injects `model_provider = "headroom"` for Codex, which
|
|
|
|
|
Codex Desktop filters its history menu by. Without retagging, existing native
|
|
|
|
|
`openai` threads vanish from the sidebar/search (#961). `_ensure_codex_provider`
|
|
|
|
|
must retag existing threads openai->headroom so the history stays visible —
|
|
|
|
|
the same reconciliation the install and wrap paths already perform."""
|
|
|
|
|
import sqlite3
|
|
|
|
|
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
codex_home = tmp_path / ".codex"
|
|
|
|
|
config_path = codex_home / "config.toml"
|
|
|
|
|
# Codex Desktop reads <codex_home>/sqlite/state_5.sqlite.
|
|
|
|
|
db = codex_home / "sqlite" / "state_5.sqlite"
|
|
|
|
|
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 (?, ?)",
|
|
|
|
|
[("t1", "openai"), ("t2", "openai"), ("t3", "anthropic")],
|
|
|
|
|
)
|
|
|
|
|
conn.commit()
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
|
|
|
|
|
init_cli._ensure_codex_provider(config_path, 8787)
|
|
|
|
|
|
|
|
|
|
conn = sqlite3.connect(str(db))
|
|
|
|
|
try:
|
|
|
|
|
counts = dict(
|
|
|
|
|
conn.execute("SELECT model_provider, COUNT(*) FROM threads GROUP BY model_provider")
|
|
|
|
|
)
|
|
|
|
|
finally:
|
|
|
|
|
conn.close()
|
|
|
|
|
# Native threads now live under the active headroom provider (stay visible);
|
|
|
|
|
# third-party providers are left untouched.
|
|
|
|
|
assert counts.get("headroom") == 2, f"existing openai threads not retagged: {counts}"
|
|
|
|
|
assert counts.get("openai", 0) == 0, f"openai threads still hidden: {counts}"
|
|
|
|
|
assert counts.get("anthropic") == 1, f"third-party provider must be left alone: {counts}"
|
|
|
|
|
|
|
|
|
|
|
2026-05-06 10:56:27 -05:00
|
|
|
def test_init_codex_strip_removes_openai_base_url(monkeypatch, tmp_path: Path) -> None:
|
|
|
|
|
"""_strip_codex_init_block must remove both the managed block and any orphaned
|
|
|
|
|
openai_base_url lines left by a crashed or partial init."""
|
|
|
|
|
init_cli, _ = _load_init_module(monkeypatch)
|
|
|
|
|
|
|
|
|
|
# Normal install-then-strip cycle.
|
|
|
|
|
path = tmp_path / "config.toml"
|
|
|
|
|
path.write_text('model = "gpt-4o"\n', encoding="utf-8")
|
|
|
|
|
init_cli._ensure_codex_provider(path, 8787)
|
|
|
|
|
assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in path.read_text(encoding="utf-8")
|
|
|
|
|
|
|
|
|
|
stripped = init_cli._strip_codex_init_block(path.read_text(encoding="utf-8"))
|
|
|
|
|
assert "openai_base_url" not in stripped, (
|
|
|
|
|
f"_strip_codex_init_block must remove openai_base_url after install:\n{stripped}"
|
|
|
|
|
)
|
|
|
|
|
assert "requires_openai_auth" not in stripped
|
|
|
|
|
assert 'model = "gpt-4o"' in stripped
|
|
|
|
|
|
|
|
|
|
# Orphan-cleanup path: openai_base_url left outside marker block.
|
|
|
|
|
orphan_content = (
|
|
|
|
|
'model = "gpt-4o"\n'
|
|
|
|
|
'openai_base_url = "http://127.0.0.1:8787/v1"\n'
|
|
|
|
|
'model_provider = "headroom"\n'
|
|
|
|
|
)
|
|
|
|
|
orphan_stripped = init_cli._strip_codex_init_block(orphan_content)
|
|
|
|
|
assert "openai_base_url" not in orphan_stripped, (
|
|
|
|
|
f"_strip_codex_init_block must remove orphaned openai_base_url:\n{orphan_stripped}"
|
|
|
|
|
)
|
|
|
|
|
assert 'model = "gpt-4o"' in orphan_stripped
|