headroom/tests/test_cli/test_init_cli.py

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

1541 lines
56 KiB
Python
Raw Normal View History

fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:55:12 -05:00
from __future__ import annotations
import importlib
import json
import os
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:55:12 -05:00
import sys
import types
fix(init): guard persistent task startup (#616) ## Description Prevent `headroom init` hooks from spawning duplicate persistent-task runners while a proxy is still starting. Fixes #615 ## 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) ## Problem `_ensure_profile_running()` checked readiness for only one second and then launched `start_detached_agent()` whenever the proxy was not ready yet. When Claude/Codex hooks fired close together, each hook could race through that path and spawn another detached persistent-task runner. ## Changes Made - Add a profile-local, nonblocking runtime start lock around init hook startup. - Re-check readiness after acquiring the lock so late-arriving hooks do not start a duplicate runner. - If a runtime is already alive, wait up to 15 seconds for readiness before stopping and restarting it. - Add regression tests for lock contention, slow startup, and cross-process lock behavior. ## 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 ``` UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py # 89 passed in 0.61s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check . # All checks passed! UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check . # 775 files already formatted UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports # Success: no issues found in 346 source files ``` Manual sandbox check: ``` # before this change: 3 ensure calls spawned 3 detached starts # after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting ``` ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Docs and CHANGELOG were left unchanged because this is a small runtime bug fix with no user-facing CLI/API change.
2026-06-11 10:34:43 +09:00
from contextlib import contextmanager
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:55:12 -05:00
from pathlib import Path
from types import SimpleNamespace
try:
import tomllib
except ModuleNotFoundError: # Python < 3.11
import tomli as tomllib # type: ignore[no-redef]
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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:
"""``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)
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"])
# 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"):
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
assert 'env_key = "OPENAI_API_KEY"' not in content
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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",
}
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
assert 'env_key = "OPENAI_API_KEY"' not in content
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 15:55:12 -05: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")
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
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")
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(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")
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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))
fix(init): guard persistent task startup (#616) ## Description Prevent `headroom init` hooks from spawning duplicate persistent-task runners while a proxy is still starting. Fixes #615 ## 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) ## Problem `_ensure_profile_running()` checked readiness for only one second and then launched `start_detached_agent()` whenever the proxy was not ready yet. When Claude/Codex hooks fired close together, each hook could race through that path and spawn another detached persistent-task runner. ## Changes Made - Add a profile-local, nonblocking runtime start lock around init hook startup. - Re-check readiness after acquiring the lock so late-arriving hooks do not start a duplicate runner. - If a runtime is already alive, wait up to 15 seconds for readiness before stopping and restarting it. - Add regression tests for lock contention, slow startup, and cross-process lock behavior. ## 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 ``` UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py # 89 passed in 0.61s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check . # All checks passed! UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check . # 775 files already formatted UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports # Success: no issues found in 346 source files ``` Manual sandbox check: ``` # before this change: 3 ensure calls spawned 3 detached starts # after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting ``` ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Docs and CHANGELOG were left unchanged because this is a small runtime bug fix with no user-facing CLI/API change.
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)
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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
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 == ""
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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 == []
fix(init): guard persistent task startup (#616) ## Description Prevent `headroom init` hooks from spawning duplicate persistent-task runners while a proxy is still starting. Fixes #615 ## 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) ## Problem `_ensure_profile_running()` checked readiness for only one second and then launched `start_detached_agent()` whenever the proxy was not ready yet. When Claude/Codex hooks fired close together, each hook could race through that path and spawn another detached persistent-task runner. ## Changes Made - Add a profile-local, nonblocking runtime start lock around init hook startup. - Re-check readiness after acquiring the lock so late-arriving hooks do not start a duplicate runner. - If a runtime is already alive, wait up to 15 seconds for readiness before stopping and restarting it. - Add regression tests for lock contention, slow startup, and cross-process lock behavior. ## 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 ``` UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py # 89 passed in 0.61s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check . # All checks passed! UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check . # 775 files already formatted UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports # Success: no issues found in 346 source files ``` Manual sandbox check: ``` # before this change: 3 ensure calls spawned 3 detached starts # after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting ``` ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Docs and CHANGELOG were left unchanged because this is a small runtime bug fix with no user-facing CLI/API change.
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")
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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")
fix(init): guard persistent task startup (#616) ## Description Prevent `headroom init` hooks from spawning duplicate persistent-task runners while a proxy is still starting. Fixes #615 ## 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) ## Problem `_ensure_profile_running()` checked readiness for only one second and then launched `start_detached_agent()` whenever the proxy was not ready yet. When Claude/Codex hooks fired close together, each hook could race through that path and spawn another detached persistent-task runner. ## Changes Made - Add a profile-local, nonblocking runtime start lock around init hook startup. - Re-check readiness after acquiring the lock so late-arriving hooks do not start a duplicate runner. - If a runtime is already alive, wait up to 15 seconds for readiness before stopping and restarting it. - Add regression tests for lock contention, slow startup, and cross-process lock behavior. ## 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 ``` UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy pytest tests/test_cli/test_init_cli.py tests/test_cli/test_install_cli.py tests/test_cli/test_wrap_persistent.py tests/test_install/test_runtime.py # 89 passed in 0.61s UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff check . # All checks passed! UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy ruff format --check . # 775 files already formatted UV_SKIP_WHEEL_FILENAME_CHECK=1 uv run --frozen --extra dev --extra proxy mypy headroom --ignore-missing-imports # Success: no issues found in 346 source files ``` Manual sandbox check: ``` # before this change: 3 ensure calls spawned 3 detached starts # after this change: 3 ensure calls spawned 1 detached start while the runtime was still starting ``` ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes Docs and CHANGELOG were left unchanged because this is a small runtime bug fix with no user-facing CLI/API change.
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 == []
fix(init): guide users when no agents are auto-detected Fixes #245. Running ``headroom init -g`` with no supported agents on PATH previously produced a single-line ClickException that read like the -g flag had been removed: Error: No supported user init targets were auto-detected. Specify one explicitly. This left reporter #245 concluding the feature was gone. Replace that message with a structured diagnostic that: * states which scope (user / local) was tried * lists every target probed (claude, codex, copilot, openclaw) and the shutil.which() result for each * explicitly confirms that -g / --global is still a supported flag * shows the concrete per-target invocation for each agent (``headroom init -g claude``, ...) so the user knows the escape hatch The implementation factors ``detect_init_targets`` into a ``_probe_init_targets`` helper that returns ``[(name, which_result)]``. ``detect_init_targets`` keeps its existing signature so the test suite and external imports aren't broken; the new helper backs both the auto-detection path and the diagnostic error formatter. Unit tests in tests/test_cli/test_init_cli.py cover: * the end-to-end message shape (structural markers + every target name + the example invocation) * the local-scope variant omitting global-only agents (copilot / openclaw) * that found binaries are surfaced with their absolute path so users can debug cases where shutil.which returns an unexpected result No behavior change when at least one target is detected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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"]
# ---------------------------------------------------------------------------
# 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}"
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