from __future__ import annotations import json import os from pathlib import Path import click import pytest from headroom.install.models import DeploymentManifest, ManagedMutation from headroom.install.providers import _apply_windows_env_scope, _remove_windows_env_scope from headroom.providers.claude.install import apply_provider_scope as apply_claude_provider_scope from headroom.providers.claude.install import build_install_env as build_claude_install_env from headroom.providers.claude.install import revert_provider_scope as revert_claude_provider_scope from headroom.providers.codex.install import apply_provider_scope as apply_codex_provider_scope from headroom.providers.codex.install import build_install_env as build_codex_install_env from headroom.providers.codex.install import revert_provider_scope as revert_codex_provider_scope from headroom.providers.copilot.install import build_install_env as build_copilot_install_env from headroom.providers.opencode.install import ( apply_provider_scope as apply_opencode_provider_scope, ) from headroom.providers.opencode.install import build_install_env as build_opencode_install_env from headroom.providers.opencode.install import ( revert_provider_scope as revert_opencode_provider_scope, ) def _manifest(tmp_path: Path) -> DeploymentManifest: return DeploymentManifest( profile="default", preset="persistent-service", runtime_kind="python", supervisor_kind="service", scope="provider", provider_mode="manual", targets=["claude", "codex"], port=8787, host="127.0.0.1", backend="anthropic", memory_db_path=str(tmp_path / "memory.db"), tool_envs={ "claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}, "codex": {"OPENAI_BASE_URL": "http://127.0.0.1:8787/v1"}, }, ) def test_apply_and_revert_claude_provider_scope(monkeypatch, tmp_path: Path) -> None: settings_path = tmp_path / "settings.json" settings_path.write_text( json.dumps({"env": {"ANTHROPIC_API_KEY": "keep", "ANTHROPIC_BASE_URL": "https://old"}}) ) monkeypatch.setattr( "headroom.providers.claude.install.claude_settings_path", lambda: settings_path ) manifest = _manifest(tmp_path) mutation = apply_claude_provider_scope(manifest) payload = json.loads(settings_path.read_text()) assert payload["env"]["ANTHROPIC_BASE_URL"] == "http://127.0.0.1:8787" assert payload["env"]["ANTHROPIC_API_KEY"] == "keep" assert mutation is not None revert_claude_provider_scope(mutation, manifest) reverted = json.loads(settings_path.read_text()) assert reverted["env"]["ANTHROPIC_BASE_URL"] == "https://old" assert reverted["env"]["ANTHROPIC_API_KEY"] == "keep" def test_apply_and_revert_codex_provider_scope(monkeypatch, tmp_path: Path) -> None: config_path = tmp_path / "config.toml" config_path.write_text('model = "gpt-4o"\n') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) mutation = apply_codex_provider_scope(manifest) content = config_path.read_text() assert 'model_provider = "headroom"' in content assert 'base_url = "http://127.0.0.1:8787/v1"' in content assert 'env_key = "OPENAI_API_KEY"' not in content assert "requires_openai_auth" not in content assert mutation is not None revert_codex_provider_scope(mutation, manifest) reverted = config_path.read_text() assert 'model_provider = "headroom"' not in reverted assert reverted.strip() == 'model = "gpt-4o"' def test_apply_codex_provider_scope_emits_flag_for_chatgpt_auth( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "config.toml" (tmp_path / "auth.json").write_text('{"auth_mode": "chatgpt"}') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) apply_codex_provider_scope(manifest) assert "requires_openai_auth = true" in config_path.read_text() def test_apply_codex_provider_scope_lands_model_provider_at_root( monkeypatch, tmp_path: Path ) -> None: """model_provider must sit above the first [table] and override any prior value. Codex scopes bare keys under the preceding table header, so appending the provider block after an existing [table] (e.g. [features]) leaves model_provider ignored and routing never switches (#260). """ config_path = tmp_path / "config.toml" config_path.write_text('model_provider = "openai"\n\n[features]\nweb_search = true\n') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) apply_codex_provider_scope(manifest) content = config_path.read_text() # Prior "openai" assignment is overridden, not duplicated. assert content.count("model_provider =") == 1 assert 'model_provider = "headroom"' in content # The managed key must land before the first table header. assert content.index('model_provider = "headroom"') < content.index("[features]") # The user's own table survives. assert "web_search = true" in content def test_apply_codex_provider_scope_preserves_table_scoped_provider_keys( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "config.toml" config_path.write_text( 'model_provider = "openai"\n\n' "[profiles.work]\n" 'model_provider = "native"\n' 'openai_base_url = "https://example.invalid/v1"\n' ) monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) apply_codex_provider_scope(manifest) content = config_path.read_text() assert content.count('model_provider = "headroom"') == 1 assert 'model_provider = "openai"' not in content assert 'model_provider = "native"' in content assert 'openai_base_url = "https://example.invalid/v1"' in content def test_codex_build_install_env_returns_proxy_base_url() -> None: env = build_codex_install_env(port=5566, backend="ignored") assert env == {"OPENAI_BASE_URL": "http://127.0.0.1:5566/v1"} def test_apply_codex_provider_scope_skips_non_provider_scope(monkeypatch, tmp_path: Path) -> None: config_path = tmp_path / "config.toml" monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) manifest.scope = "user" mutation = apply_codex_provider_scope(manifest) assert mutation is None assert not config_path.exists() def test_apply_codex_provider_scope_replaces_existing_managed_block( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "config.toml" config_path.write_text( 'model = "gpt-4o"\n\n' "# --- Headroom persistent provider ---\n" 'model_provider = "headroom"\n\n' "[model_providers.headroom]\n" 'name = "Headroom persistent proxy"\n' 'base_url = "http://127.0.0.1:1111/v1"\n' "requires_openai_auth = true\n" "supports_websockets = true\n" "# --- end Headroom persistent provider ---\n" ) monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) manifest.port = 9999 apply_codex_provider_scope(manifest) content = config_path.read_text() assert content.count("# --- Headroom persistent provider ---") == 1 assert 'base_url = "http://127.0.0.1:9999/v1"' in content assert 'base_url = "http://127.0.0.1:1111/v1"' not in content # Bug 3 (#406): the replacement block must NOT carry requires_openai_auth. assert "requires_openai_auth" not in content def test_apply_codex_provider_scope_creates_new_config_when_missing( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "nested" / "config.toml" monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) mutation = apply_codex_provider_scope(manifest) assert mutation is not None assert 'base_url = "http://127.0.0.1:8787/v1"' in config_path.read_text() def test_revert_codex_provider_scope_ignores_missing_path_and_file(tmp_path: Path) -> None: manifest = _manifest(tmp_path) revert_codex_provider_scope( ManagedMutation(target="codex", kind="toml-block"), manifest, ) revert_codex_provider_scope( ManagedMutation( target="codex", kind="toml-block", path=str(tmp_path / "missing.toml"), ), manifest, ) def test_revert_codex_provider_scope_ignores_files_without_managed_block( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "config.toml" config_path.write_text('model = "gpt-4o"\n') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) mutation = ManagedMutation(target="codex", kind="toml-block", path=str(config_path)) revert_codex_provider_scope(mutation, manifest) assert config_path.read_text() == 'model = "gpt-4o"\n' def test_apply_openclaw_provider_scope_uses_manifest_port(monkeypatch, tmp_path: Path) -> None: recorded: list[list[str]] = [] monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: "openclaw") monkeypatch.setattr( "headroom.providers.openclaw.install.resolve_headroom_command", lambda: ["headroom"], ) monkeypatch.setattr( "headroom.providers.openclaw.install._invoke_openclaw", lambda command: recorded.append(command), ) monkeypatch.setattr( "headroom.providers.openclaw.install.openclaw_config_path", lambda: tmp_path / "openclaw.json", ) manifest = _manifest(tmp_path) manifest.port = 9999 from headroom.providers.openclaw.install import ( apply_provider_scope as apply_openclaw_provider_scope, ) apply_openclaw_provider_scope(manifest) assert recorded == [["headroom", "wrap", "openclaw", "--no-auto-start", "--proxy-port", "9999"]] def test_openclaw_apply_provider_scope_requires_installed_binary( tmp_path: Path, monkeypatch ) -> None: monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: None) with pytest.raises(click.ClickException, match="openclaw not found"): from headroom.providers.openclaw.install import ( apply_provider_scope as apply_openclaw_provider_scope, ) apply_openclaw_provider_scope(_manifest(tmp_path)) def test_openclaw_helper_wrappers_delegate_to_stdlib(monkeypatch) -> None: monkeypatch.setattr("shutil.which", lambda name: f"/fake/{name}") recorded: list[tuple[list[str], bool]] = [] def fake_run(command: list[str], check: bool) -> None: recorded.append((command, check)) monkeypatch.setattr("subprocess.run", fake_run) from headroom.providers.openclaw.install import _invoke_openclaw, shutil_which assert shutil_which("openclaw") == "/fake/openclaw" _invoke_openclaw(["headroom", "wrap", "openclaw"]) assert recorded == [(["headroom", "wrap", "openclaw"], True)] def test_openclaw_revert_provider_scope_skips_without_binary(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: None) called = False def fail_if_called(command: list[str]) -> None: nonlocal called called = True monkeypatch.setattr("headroom.providers.openclaw.install._invoke_openclaw", fail_if_called) from headroom.providers.openclaw.install import ( revert_provider_scope as revert_openclaw_provider_scope, ) revert_openclaw_provider_scope( ManagedMutation(target="openclaw", kind="openclaw-wrap", path=str(tmp_path / "cfg.json")), _manifest(tmp_path), ) assert called is False def test_openclaw_revert_provider_scope_invokes_unwrap(monkeypatch, tmp_path: Path) -> None: recorded: list[list[str]] = [] monkeypatch.setattr("headroom.providers.openclaw.install.shutil_which", lambda name: "openclaw") monkeypatch.setattr( "headroom.providers.openclaw.install.resolve_headroom_command", lambda: ["headroom"], ) monkeypatch.setattr( "headroom.providers.openclaw.install._invoke_openclaw", lambda command: recorded.append(command), ) from headroom.providers.openclaw.install import ( revert_provider_scope as revert_openclaw_provider_scope, ) revert_openclaw_provider_scope( ManagedMutation(target="openclaw", kind="openclaw-wrap", path=str(tmp_path / "cfg.json")), _manifest(tmp_path), ) assert recorded == [["headroom", "unwrap", "openclaw"]] def test_windows_env_scope_restores_previous_values(monkeypatch, tmp_path: Path) -> None: manifest = _manifest(tmp_path) manifest.scope = "user" manifest.targets = ["claude"] manifest.base_env = {"HEADROOM_PORT": "8787"} manifest.tool_envs = {"claude": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"}} calls: list[list[str]] = [] previous_values = { "HEADROOM_PORT": "7777", "ANTHROPIC_BASE_URL": "https://old", } class Result: def __init__(self, stdout: str = "") -> None: self.stdout = stdout def fake_run(command: list[str], **kwargs): calls.append(command) script = command[-1] if "GetEnvironmentVariable" in script: name = script.split("GetEnvironmentVariable('", 1)[1].split("'", 1)[0] value = previous_values.get(name, "__HEADROOM_UNSET__") return Result(stdout=value) return Result() monkeypatch.setattr("headroom.install.providers.subprocess.run", fake_run) mutations = _apply_windows_env_scope(manifest) _remove_windows_env_scope(mutations) previous_by_name = {mutation.data["name"]: mutation.data["previous"] for mutation in mutations} assert previous_by_name["HEADROOM_PORT"] == "7777" assert previous_by_name["ANTHROPIC_BASE_URL"] == "https://old" assert any( "[Environment]::SetEnvironmentVariable('HEADROOM_PORT','7777','User')" in command[-1] for command in calls ) assert any( "[Environment]::SetEnvironmentVariable('ANTHROPIC_BASE_URL','https://old','User')" in command[-1] for command in calls ) def test_remove_windows_env_scope_requires_name_and_scope() -> None: try: _remove_windows_env_scope([ManagedMutation(target="env", kind="windows-env", data={})]) except ValueError as exc: assert "variable name" in str(exc) else: raise AssertionError("expected missing variable name to raise") try: _remove_windows_env_scope( [ManagedMutation(target="env", kind="windows-env", data={"name": "X", "scope": 1})] ) except ValueError as exc: assert "valid scope" in str(exc) else: raise AssertionError("expected invalid scope to raise") def test_apply_mutations_runs_openclaw_for_user_scope(monkeypatch, tmp_path: Path) -> None: manifest = _manifest(tmp_path) manifest.scope = "user" manifest.targets = ["openclaw"] manifest.base_env = {"HEADROOM_PORT": "8787"} manifest.tool_envs = {} if os.name == "nt": monkeypatch.setattr( "headroom.install.providers._apply_windows_env_scope", lambda deployment: [] ) else: monkeypatch.setattr( "headroom.install.providers._apply_unix_env_scope", lambda deployment: [] ) monkeypatch.setattr( "headroom.install.providers.apply_provider_scope_mutations", lambda deployment: [ManagedMutation(target="openclaw", kind="openclaw-wrap")], ) from headroom.install.providers import apply_mutations mutations = apply_mutations(manifest) assert [mutation.kind for mutation in mutations] == ["openclaw-wrap"] def test_claude_build_install_env_returns_proxy_base_url() -> None: # Arrange / Act env = build_claude_install_env(port=5566, backend="ignored") # Assert assert env == { "ANTHROPIC_BASE_URL": "http://127.0.0.1:5566", "ENABLE_TOOL_SEARCH": "true", } def test_copilot_build_install_env_uses_provider_type_specific_proxy_urls() -> None: anthropic_env = build_copilot_install_env(port=8787, backend="anthropic") openai_env = build_copilot_install_env(port=8787, backend="anyllm") assert anthropic_env == { "COPILOT_PROVIDER_TYPE": "anthropic", "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:8787", } assert openai_env == { "COPILOT_PROVIDER_TYPE": "openai", "COPILOT_PROVIDER_BASE_URL": "http://127.0.0.1:8787/v1", "COPILOT_PROVIDER_WIRE_API": "completions", } def test_apply_claude_provider_scope_skips_non_provider_scope(monkeypatch, tmp_path: Path) -> None: # Arrange settings_path = tmp_path / "settings.json" monkeypatch.setattr( "headroom.providers.claude.install.claude_settings_path", lambda: settings_path ) manifest = _manifest(tmp_path) manifest.scope = "user" # Act mutation = apply_claude_provider_scope(manifest) # Assert assert mutation is None assert not settings_path.exists() def test_revert_claude_provider_scope_removes_new_values_from_non_mapping_env( monkeypatch, tmp_path: Path ) -> None: # Arrange settings_path = tmp_path / "settings.json" settings_path.write_text(json.dumps({"env": ["not-a-map"]})) monkeypatch.setattr( "headroom.providers.claude.install.claude_settings_path", lambda: settings_path ) manifest = _manifest(tmp_path) # Act mutation = apply_claude_provider_scope(manifest) apply_payload = json.loads(settings_path.read_text()) revert_claude_provider_scope(mutation, manifest) reverted_payload = json.loads(settings_path.read_text()) # Assert assert mutation is not None assert mutation.data["previous"] == {"ANTHROPIC_BASE_URL": None} assert apply_payload["env"] == {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"} assert reverted_payload["env"] == {} def test_apply_claude_provider_scope_creates_settings_when_missing( monkeypatch, tmp_path: Path ) -> None: # Arrange settings_path = tmp_path / "nested" / "settings.json" monkeypatch.setattr( "headroom.providers.claude.install.claude_settings_path", lambda: settings_path ) manifest = _manifest(tmp_path) # Act mutation = apply_claude_provider_scope(manifest) # Assert assert mutation is not None assert json.loads(settings_path.read_text()) == { "env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:8787"} } def test_revert_claude_provider_scope_ignores_missing_mutation_path(tmp_path: Path) -> None: # Arrange manifest = _manifest(tmp_path) mutation = ManagedMutation(target="claude", kind="json-env", data={"previous": {}}) # Act / Assert revert_claude_provider_scope(mutation, manifest) def test_revert_claude_provider_scope_ignores_missing_settings_file(tmp_path: Path) -> None: # Arrange manifest = _manifest(tmp_path) mutation = ManagedMutation( target="claude", kind="json-env", path=str(tmp_path / "missing-settings.json"), data={"previous": {}}, ) # Act / Assert revert_claude_provider_scope(mutation, manifest) # --------------------------------------------------------------------------- # OpenCode provider tests # --------------------------------------------------------------------------- def test_opencode_build_install_env_leaves_provider_env_unset() -> None: env = build_opencode_install_env(port=5566, backend="ignored") assert env == {} def test_apply_and_revert_opencode_provider_scope(monkeypatch, tmp_path: Path) -> None: config_path = tmp_path / "opencode.json" config_path.write_text('{"model": "openai/gpt-4o"}') monkeypatch.setattr( "headroom.providers.opencode.install.opencode_config_path", lambda: config_path ) manifest = _manifest(tmp_path) mutation = apply_opencode_provider_scope(manifest) assert mutation is not None assert mutation.target == "opencode" assert mutation.kind == "json-block" content = config_path.read_text() data = json.loads(content) assert data["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1" assert data["model"] == "openai/gpt-4o" # user model preserved revert_opencode_provider_scope(mutation, manifest) reverted = json.loads(config_path.read_text()) assert reverted["model"] == "openai/gpt-4o" assert "headroom" not in reverted.get("provider", {}) def test_apply_opencode_provider_scope_skips_non_provider_scope( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "opencode.json" monkeypatch.setattr( "headroom.providers.opencode.install.opencode_config_path", lambda: config_path ) manifest = _manifest(tmp_path) manifest.scope = "user" mutation = apply_opencode_provider_scope(manifest) assert mutation is None assert not config_path.exists() def test_apply_opencode_provider_scope_creates_new_config_when_missing( monkeypatch, tmp_path: Path ) -> None: config_path = tmp_path / "nested" / "opencode.json" monkeypatch.setattr( "headroom.providers.opencode.install.opencode_config_path", lambda: config_path ) manifest = _manifest(tmp_path) mutation = apply_opencode_provider_scope(manifest) assert mutation is not None data = json.loads(config_path.read_text()) assert data["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1" def test_revert_opencode_provider_scope_ignores_missing_path_and_file( tmp_path: Path, ) -> None: manifest = _manifest(tmp_path) revert_opencode_provider_scope( ManagedMutation(target="opencode", kind="json-block"), manifest, ) revert_opencode_provider_scope( ManagedMutation( target="opencode", kind="json-block", path=str(tmp_path / "missing.json"), ), manifest, ) # --------------------------------------------------------------------------- # Bug 3 regression tests (#406): requires_openai_auth and openai_base_url # must never appear in the headroom provider block. # --------------------------------------------------------------------------- def test_headroom_provider_block_never_sets_requires_openai_auth( monkeypatch, tmp_path: Path ) -> None: """apply_provider_scope must NOT emit requires_openai_auth in the headroom block. Bug 3 (#406): requires_openai_auth = true on a custom [model_providers.headroom] block forces codex to demand OpenAI OAuth login for headroom-routed traffic. Headroom is a local proxy — it must not require OpenAI auth. """ for port in (8787, 9999): config_path = tmp_path / f"config_{port}.toml" monkeypatch.setattr( "headroom.providers.codex.install.codex_config_path", lambda _p=config_path: _p, ) manifest = _manifest(tmp_path) manifest.port = port apply_codex_provider_scope(manifest) content = config_path.read_text() # The rendered TOML for the headroom provider block must not contain this # field. If it does, codex will prompt for OpenAI OAuth on every startup. assert "requires_openai_auth" not in content, ( f"requires_openai_auth must be absent from the headroom provider block " f"(port={port}); got:\n{content}" ) # Sanity: the block itself is present and points at the right port. assert f'base_url = "http://127.0.0.1:{port}/v1"' in content assert "[model_providers.headroom]" in content def test_inject_codex_provider_config_writes_openai_base_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """_inject_codex_provider_config MUST write a top-level openai_base_url key. Bug 3 (#406) has two halves: 1. Strip ``requires_openai_auth`` from the headroom provider block (done in 3ca48d3). 2. Inject ``openai_base_url`` at the top level so subscription (ChatGPT plan) users are also routed through headroom. Without the top-level ``openai_base_url`` override, Codex subscription mode uses the built-in ``openai`` provider with ``chatgpt.com/backend-api/codex`` as the base URL, bypassing the proxy entirely. This key is the only way to intercept subscription traffic. """ from headroom.cli import wrap as wrap_mod home = tmp_path monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) wrap_mod._inject_codex_provider_config(8787) config_file = home / ".codex" / "config.toml" assert config_file.exists(), "inject must create ~/.codex/config.toml" content = config_file.read_text() # The top-level openai_base_url key must be present at exactly this value. # Any port drift (e.g. 9999) will break subscription routing and must cause # this test to fail. assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content, ( "openai_base_url must appear at the top level of config.toml after injection " "so that Codex subscription (ChatGPT plan) users are routed through headroom; " f"got:\n{content}" ) # Sanity: the provider block is actually there. assert "[model_providers.headroom]" in content assert 'base_url = "http://127.0.0.1:8787/v1"' in content # requires_openai_auth must also be absent (bug 3 regression guard). assert "requires_openai_auth" not in content, ( f"requires_openai_auth must be absent from the injected provider block; got:\n{content}" ) # openai_base_url must be in the top-level block, NOT inside [model_providers.*] lines = content.splitlines() in_provider_section = False for line in lines: stripped = line.strip() if stripped.startswith("["): in_provider_section = stripped.startswith("[model_providers") if in_provider_section and stripped.startswith("openai_base_url"): raise AssertionError( f"openai_base_url must be top-level, not inside a [model_providers.*] section; " f"found it inside a provider section. Config:\n{content}" ) def test_unwrap_removes_top_level_openai_base_url( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: """After unwrap, the top-level openai_base_url key must be gone. Verifies that ``_restore_codex_provider_config`` (unwrap) removes the ``openai_base_url`` key so orphaned entries don't accumulate between wrap/unwrap cycles. """ from headroom.cli import wrap as wrap_mod home = tmp_path monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) wrap_mod._inject_codex_provider_config(8787) config_file = home / ".codex" / "config.toml" assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_file.read_text() wrap_mod._restore_codex_provider_config() # After unwrap the file should be gone (no prior content) or cleaned. if config_file.exists(): content = config_file.read_text() assert "openai_base_url" not in content, ( f"openai_base_url must not remain in config.toml after unwrap; got:\n{content}" ) # Also verify via _strip_codex_headroom_blocks directly — the orphan-cleanup # path is exercised when there is no backup file (crash-recovery path). orphan_content = ( 'model = "gpt-4o"\n' 'openai_base_url = "http://127.0.0.1:8787/v1"\n' 'model_provider = "headroom"\n' ) stripped = wrap_mod._strip_codex_headroom_blocks(orphan_content) assert "openai_base_url" not in stripped, ( f"_strip_codex_headroom_blocks must remove orphaned openai_base_url lines; got:\n{stripped}" ) assert 'model = "gpt-4o"' in stripped # --------------------------------------------------------------------------- # Bug 3 (#406): openai_base_url must appear in persistent-install and init paths # --------------------------------------------------------------------------- def test_apply_provider_scope_writes_openai_base_url(monkeypatch, tmp_path: Path) -> None: """apply_provider_scope must write openai_base_url at the top level (not inside a [model_providers.*] block) so subscription (ChatGPT plan) users are routed through headroom regardless of which entry point they used.""" config_path = tmp_path / "config.toml" config_path.write_text('model = "gpt-4o"\n') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) manifest.port = 8787 apply_codex_provider_scope(manifest) content = config_path.read_text() # Must be present as a top-level key. assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in content, ( f"openai_base_url missing from persistent-install config:\n{content}" ) # Must NOT be inside any [model_providers.*] block — the key must appear # before the first [section] header that follows it. lines = content.splitlines() in_provider_section = False for line in lines: stripped = line.strip() if stripped.startswith("["): in_provider_section = True if in_provider_section and stripped.startswith("openai_base_url"): raise AssertionError(f"openai_base_url appeared inside a section block:\n{content}") # Bug 3 regression: requires_openai_auth must NOT appear. assert "requires_openai_auth" not in content, ( f"requires_openai_auth must not be present in config:\n{content}" ) def test_persistent_install_strip_removes_openai_base_url(monkeypatch, tmp_path: Path) -> None: """revert_provider_scope must remove openai_base_url during uninstall, and must also clean up orphaned openai_base_url lines left by a crashed install.""" config_path = tmp_path / "config.toml" config_path.write_text('model = "gpt-4o"\n') monkeypatch.setattr("headroom.providers.codex.install.codex_config_path", lambda: config_path) manifest = _manifest(tmp_path) manifest.port = 8787 mutation = apply_codex_provider_scope(manifest) assert mutation is not None assert 'openai_base_url = "http://127.0.0.1:8787/v1"' in config_path.read_text() revert_codex_provider_scope(mutation, manifest) reverted = config_path.read_text() assert "openai_base_url" not in reverted, ( f"openai_base_url must be removed after revert:\n{reverted}" ) assert "requires_openai_auth" not in reverted # User's original content must survive. assert 'model = "gpt-4o"' in reverted # Also verify orphan-cleanup path: revert on a config where openai_base_url # was left outside the marker block (crash-recovery scenario). config_path.write_text( 'model = "gpt-4o"\n' 'openai_base_url = "http://127.0.0.1:8787/v1"\n' 'model_provider = "headroom"\n' ) revert_codex_provider_scope( ManagedMutation(target="codex", kind="toml-block", path=str(config_path)), manifest, ) orphan_reverted = config_path.read_text() assert "openai_base_url" not in orphan_reverted, ( f"orphaned openai_base_url must be removed by revert:\n{orphan_reverted}" ) assert 'model = "gpt-4o"' in orphan_reverted # --------------------------------------------------------------------------- # Planner-level opencode smoke tests # --------------------------------------------------------------------------- def test_planner_resolves_opencode_as_install_target() -> None: from headroom.install.planner import resolve_targets targets = resolve_targets("manual", ["opencode"]) assert "opencode" in targets def test_planner_opencode_in_supported_targets_enum() -> None: from headroom.install.models import ToolTarget from headroom.install.planner import PROVIDER_SCOPE_TARGETS, SUPPORTED_TARGETS assert ToolTarget.OPENCODE in SUPPORTED_TARGETS assert ToolTarget.OPENCODE in PROVIDER_SCOPE_TARGETS def test_planner_opencode_in_provider_scope_targets() -> None: from headroom.install.planner import resolve_targets targets = resolve_targets("manual", ["opencode"], scope="provider") assert "opencode" in targets def test_planner_build_tool_envs_includes_opencode() -> None: from headroom.install.planner import build_tool_envs envs = build_tool_envs(port=8787, backend="anthropic", targets=["opencode"]) assert "opencode" in envs assert envs["opencode"] == {} def test_planner_resolve_all_includes_opencode() -> None: from headroom.install.planner import resolve_targets targets = resolve_targets("all", []) assert "opencode" in targets def test_planner_provider_scope_unsupported_error_excludes_opencode() -> None: import click import pytest from headroom.install.planner import resolve_targets with pytest.raises(click.ClickException, match="unsupported targets"): resolve_targets("manual", ["cursor"], scope="provider") # --------------------------------------------------------------------------- # Opencode revert OSError fallback # --------------------------------------------------------------------------- def test_revert_opencode_provider_scope_fallback_on_oserror(monkeypatch, tmp_path: Path) -> None: """revert_opencode_provider_scope falls back to strip when backup copy fails.""" config_path = tmp_path / "opencode.json" backup_path = config_path.with_suffix(".json.headroom-backup") config_path.parent.mkdir(parents=True, exist_ok=True) from headroom.install.models import ManagedMutation from headroom.providers.opencode.config import ( _PROVIDER_MARKER_END, _PROVIDER_MARKER_START, ) original = '{"model": "openai/gpt-4o"}' backup_path.write_text(original) provider_json = '{"headroom":{"npm":"@ai-sdk/openai-compatible","name":"Headroom Proxy","options":{"baseURL":"http://127.0.0.1:8787/v1"}}}' config_path.write_text( f'{_PROVIDER_MARKER_START}\n"provider": {provider_json},\n{_PROVIDER_MARKER_END}\n' ) monkeypatch.setattr( "headroom.providers.opencode.install.opencode_config_path", lambda: config_path, ) from headroom.providers.opencode.install import revert_provider_scope manifest = _manifest(tmp_path) def _fail_copy2(src, dst): msg = "permission denied" raise OSError(msg) monkeypatch.setattr("shutil.copy2", _fail_copy2) revert_provider_scope( ManagedMutation(target="opencode", kind="json-block", path=str(config_path)), manifest, ) assert backup_path.exists() # backup preserved when copy fails assert not config_path.exists() or "headroom" not in config_path.read_text()