mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description `headroom wrap opencode` currently routes `headroom/*` models only to ordinary Anthropic or OpenAI backends. A user with a GitHub Copilot subscription cannot point those `headroom/*` requests at the Copilot seat while keeping Headroom compression and stats, even though Headroom already has the validated subscription resolver, the proxy seed path, and the OpenCode provider route needed to do it. This PR adds `--copilot-subscription` to the OpenCode wrap command. It reuses the existing Copilot subscription token resolver, passes the validated endpoint and token seed into the existing proxy startup path, rejects unsupported runtime modes, and treats any non-empty Copilot API token as a private session seed so token-only sessions do not reuse a shared proxy. The generated OpenCode provider still targets the local proxy, and subscription secrets stay out of OpenCode config, environment, and terminal output. Closes #2441 ## Type of Change - [ ] Bug fix (non-breaking change that fixes an issue) - [x] 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 - Add `headroom wrap opencode --copilot-subscription` in `headroom/cli/wrap.py`. - Reuse the existing validated Copilot subscription resolver through one small required-resolution helper shared with the dedicated Copilot wrapper. - Pass the resolved endpoint and token seed into `_ensure_proxy()` as `openai_api_url`, `copilot_api_token`, `copilot_refresh_oauth_token`, and `copilot_api_token_expires_at`. - Reject `--copilot-subscription` with `--no-proxy`, `--prepare-only`, and translated backends before proxy or OpenCode launch. - Validate subscription mode before snapshotting OpenCode config, so rejected invocations don't create stale backups. - Scrub inherited Copilot proxy seed variables from the OpenCode child environment. - Treat any non-empty Copilot API token as a private session seed so token-only sessions do not reuse shared or persistent proxies. - Add focused OpenCode and persistent-proxy coverage for seed handoff, guard failures, direct-token isolation, secret non-disclosure, and unchanged non-subscription behavior. - Leave `CHANGELOG.md` untouched because Headroom generates changelog entries from conventional commits. ## Testing - [x] Unit tests pass (`uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q`, `106 passed in 136.65s`) - [x] Linting passes (`uv run ruff check headroom/cli/wrap.py tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py`) - [ ] Type checking passes (`uv run mypy headroom`) - [x] New tests added for new functionality when applicable - [ ] Manual testing performed ### Test Output ```text Targeted subscription tests pass: OpenCode `7 passed, 37 deselected in 0.36s`, persistent proxy `2 passed, 29 deselected in 0.34s`, and dedicated Copilot `11 passed, 20 deselected in 0.39s`. Coverage includes inherited resolver-input env scrubbing, HEADROOM_BACKEND rejection, no-backup-on-rejection, OpenCode-only scrub scoping, and private-proxy teardown on config-injection failure. The full focused command `uv run pytest tests/test_cli/test_wrap_opencode.py tests/test_cli/test_wrap_persistent.py tests/test_cli/test_wrap_copilot.py -q` passed with `106 passed in 136.65s`. Ruff check and format check pass. ``` ## Real Behavior Proof - Environment: Windows, `uv` development environment, local CLI tests, no live Copilot seat on this host - Exact command / steps: run the focused OpenCode and persistent-proxy tests with a mocked `CopilotSubscriptionTokenResolution`, then capture the proof rows for seed handoff, direct-token isolation, guards, and secret non-disclosure - Observed result: Targeted subscription and proxy-seed tests pass, including OpenCode-only resolver-input env scrubbing and private-proxy teardown on config-injection failure; the full focused command passed with `106 passed in 136.65s`; Ruff check and format check pass. - Not tested: live Copilot subscription seat run on this host ## 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 - [ ] 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 feature works - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable ## Additional Notes - Feature approval comes from the open `enhancement` label on https://github.com/headroomlabs-ai/headroom/issues/2441. - Keep the final live-backend claim behind manual owner proof. Local CLI tests can prove config, guard, secret, and proxy-seed behavior, but they cannot prove a real Copilot seat on this host. - `CHANGELOG.md` remains untouched because Headroom's release pipeline generates changelog entries from conventional commits.
1239 lines
48 KiB
Python
1239 lines
48 KiB
Python
"""Tests for `headroom wrap opencode` and `headroom unwrap opencode`."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from headroom.cli import wrap as wrap_mod
|
|
from headroom.cli.main import main
|
|
from headroom.copilot_auth import CopilotSubscriptionTokenResolution
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_rtk(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# RTK is opt-in (off by default); these tests exercise the RTK-on injection path.
|
|
monkeypatch.setenv("HEADROOM_RTK", "1")
|
|
|
|
|
|
@pytest.fixture
|
|
def runner() -> CliRunner:
|
|
return CliRunner()
|
|
|
|
|
|
def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
home = str(tmp_path)
|
|
monkeypatch.setenv("HOME", home)
|
|
monkeypatch.setenv("USERPROFILE", home)
|
|
monkeypatch.delenv("OPENCODE_HOME", raising=False)
|
|
monkeypatch.delenv("OPENCODE_CONFIG", raising=False)
|
|
|
|
|
|
def _subscription_resolution() -> CopilotSubscriptionTokenResolution:
|
|
return CopilotSubscriptionTokenResolution(
|
|
token="copilot-api-secret",
|
|
source="test",
|
|
confidence="test",
|
|
api_url="https://api.githubcopilot.com",
|
|
token_fingerprint="sha256:test",
|
|
refresh_oauth_token="copilot-refresh-secret",
|
|
api_token_expires_at=123.5,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wrap opencode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_wrap_opencode_copilot_subscription_handoffs_seed_after_actual_port(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN", "inherited-api-secret")
|
|
monkeypatch.setenv("GITHUB_COPILOT_REFRESH_OAUTH_TOKEN", "inherited-refresh-secret")
|
|
monkeypatch.setenv("GITHUB_COPILOT_API_TOKEN_EXPIRES_AT", "999.0")
|
|
monkeypatch.setenv("GITHUB_COPILOT_TOKEN", "inherited-seat-token")
|
|
monkeypatch.setenv("GITHUB_COPILOT_GITHUB_TOKEN", "inherited-github-token")
|
|
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "inherited-alt-github-token")
|
|
monkeypatch.setenv("COPILOT_PROVIDER_BEARER_TOKEN", "inherited-provider-bearer")
|
|
monkeypatch.setenv("GH_TOKEN", "inherited-gh-token")
|
|
monkeypatch.setenv("GITHUB_TOKEN", "inherited-github-pat")
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_ensure_proxy(*args, **kwargs): # noqa: ANN002, ANN003
|
|
captured["ensure"] = kwargs
|
|
return None, 9010
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured["launch"] = kwargs
|
|
|
|
with (
|
|
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
|
|
patch.object(
|
|
wrap_mod,
|
|
"_require_copilot_subscription_resolution",
|
|
return_value=_subscription_resolution(),
|
|
),
|
|
patch.object(wrap_mod, "_ensure_proxy", side_effect=fake_ensure_proxy),
|
|
patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool),
|
|
):
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"opencode",
|
|
"--copilot-subscription",
|
|
"--no-rtk",
|
|
"--no-mcp",
|
|
"--no-serena",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
ensure = captured["ensure"]
|
|
assert ensure["openai_api_url"] == "https://api.githubcopilot.com"
|
|
assert ensure["copilot_api_token"] == "copilot-api-secret"
|
|
assert ensure["copilot_refresh_oauth_token"] == "copilot-refresh-secret"
|
|
assert ensure["copilot_api_token_expires_at"] == 123.5
|
|
launch = captured["launch"]
|
|
assert launch["port"] == 9010
|
|
assert "copilot-api-secret" not in result.output
|
|
assert "copilot-api-secret" not in str(launch["env"])
|
|
assert "copilot-refresh-secret" not in str(launch["env"])
|
|
assert "copilot-api-secret" not in launch["env"]["OPENCODE_CONFIG_CONTENT"]
|
|
assert "GITHUB_COPILOT_API_TOKEN" not in launch["env"]
|
|
assert "GITHUB_COPILOT_REFRESH_OAUTH_TOKEN" not in launch["env"]
|
|
assert "GITHUB_COPILOT_API_TOKEN_EXPIRES_AT" not in launch["env"]
|
|
assert "GITHUB_COPILOT_TOKEN" not in launch["env"]
|
|
assert "GITHUB_COPILOT_GITHUB_TOKEN" not in launch["env"]
|
|
assert "COPILOT_GITHUB_TOKEN" not in launch["env"]
|
|
assert "COPILOT_PROVIDER_BEARER_TOKEN" not in launch["env"]
|
|
assert "GH_TOKEN" not in launch["env"]
|
|
assert "GITHUB_TOKEN" not in launch["env"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"extra_args, message",
|
|
[
|
|
(["--no-proxy"], "--no-proxy"),
|
|
(["--prepare-only"], "--prepare-only"),
|
|
(["--backend", "anyllm"], "translated backends"),
|
|
],
|
|
)
|
|
def test_wrap_opencode_copilot_subscription_rejects_incompatible_modes(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
extra_args: list[str],
|
|
message: str,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True)
|
|
config_file.write_text("{}", encoding="utf-8")
|
|
with patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")):
|
|
result = runner.invoke(
|
|
main,
|
|
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp", *extra_args],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert message in result.output
|
|
assert not config_file.with_name("opencode.json.headroom-backup").exists()
|
|
|
|
|
|
def test_wrap_opencode_copilot_subscription_rejects_headroom_backend_env(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HEADROOM_BACKEND", "anyllm")
|
|
with patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")):
|
|
result = runner.invoke(
|
|
main,
|
|
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp"],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "translated backends" in result.output
|
|
|
|
|
|
def test_wrap_opencode_copilot_subscription_requires_login_before_launch(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
with (
|
|
patch.object(
|
|
wrap_mod,
|
|
"resolve_subscription_bearer_token_details",
|
|
return_value=None,
|
|
),
|
|
patch.object(wrap_mod, "_ensure_proxy", side_effect=AssertionError("proxy launched")),
|
|
):
|
|
result = runner.invoke(
|
|
main,
|
|
["wrap", "opencode", "--copilot-subscription", "--no-rtk", "--no-mcp"],
|
|
)
|
|
assert result.exit_code == 1
|
|
assert "headroom copilot-auth login" in result.output
|
|
|
|
|
|
def test_wrap_opencode_copilot_subscription_cleans_up_proxy_on_config_failure(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
class _FakeProxy:
|
|
def __init__(self) -> None:
|
|
self.terminated = False
|
|
self.wait_timeout: float | None = None
|
|
|
|
def poll(self) -> None:
|
|
return None
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
|
|
def wait(self, timeout: float | None = None) -> int:
|
|
self.wait_timeout = timeout
|
|
return 0
|
|
|
|
proxy = _FakeProxy()
|
|
|
|
with (
|
|
patch.object(wrap_mod.shutil, "which", return_value="opencode"),
|
|
patch.object(
|
|
wrap_mod,
|
|
"_require_copilot_subscription_resolution",
|
|
return_value=_subscription_resolution(),
|
|
),
|
|
patch.object(wrap_mod, "_ensure_proxy", return_value=(proxy, 9010)),
|
|
patch.object(wrap_mod, "_register_proxy_client"),
|
|
patch.object(wrap_mod, "_unregister_proxy_client"),
|
|
patch.object(wrap_mod, "_live_proxy_clients", return_value=[]),
|
|
patch.object(
|
|
wrap_mod,
|
|
"inject_opencode_provider_config",
|
|
side_effect=RuntimeError("config write failed"),
|
|
),
|
|
patch.object(wrap_mod, "_launch_tool", side_effect=AssertionError("launch should not run")),
|
|
):
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"opencode",
|
|
"--copilot-subscription",
|
|
"--no-rtk",
|
|
"--no-mcp",
|
|
"--no-serena",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 1
|
|
assert isinstance(result.exception, RuntimeError)
|
|
assert str(result.exception) == "config write failed"
|
|
assert proxy.terminated is True
|
|
assert proxy.wait_timeout == 5
|
|
|
|
|
|
def test_wrap_opencode_sets_config_content_env(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OPENCODE_CONFIG_CONTENT env var is set with the headroom provider."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main,
|
|
["wrap", "opencode", "--port", "9000", "--no-mcp", "--", "--model", "gpt-4o"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
assert isinstance(env, dict)
|
|
assert "OPENCODE_CONFIG_CONTENT" in env
|
|
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
|
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
|
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
|
assert "model" not in config # headroom provider is a transparent pass-through
|
|
assert captured["tool_label"] == "OPENCODE"
|
|
assert captured["agent_type"] == "opencode"
|
|
assert captured["args"] == ("--model", "gpt-4o")
|
|
|
|
|
|
def test_wrap_opencode_does_not_add_base_url_env_vars(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OPENAI_BASE_URL and ANTHROPIC_BASE_URL are left to OpenCode providers."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("OPENAI_BASE_URL", "https://deepseek.example/v1")
|
|
monkeypatch.setenv("ANTHROPIC_BASE_URL", "https://anthropic.example")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
assert isinstance(env, dict)
|
|
assert env["OPENAI_BASE_URL"] == "https://deepseek.example/v1"
|
|
assert env["ANTHROPIC_BASE_URL"] == "https://anthropic.example"
|
|
|
|
|
|
def test_wrap_opencode_missing_binary_errors_clearly(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""If the opencode binary is missing the command must fail with a clear error."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value=None):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode"])
|
|
|
|
assert result.exit_code == 1
|
|
assert "'opencode' not found in PATH" in result.output
|
|
|
|
|
|
def test_wrap_opencode_prepare_only_injects_config(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`wrap opencode --prepare-only` writes the provider config to opencode.json."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
assert config_file.exists()
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
|
|
|
|
|
def test_wrap_opencode_prepare_only_registers_serena_with_agent_context(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--prepare-only"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config = json.loads(config_file.read_text())
|
|
serena_command = config["mcp"]["serena"]["command"]
|
|
assert serena_command[serena_command.index("--context") + 1] == "agent"
|
|
|
|
|
|
def test_wrap_opencode_no_mcp_skips_mcp_injection(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`--no-mcp` skips MCP server injection."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
|
assert "mcp" not in config
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
persisted_config = json.loads(config_file.read_text())
|
|
assert "headroom" not in persisted_config.get("mcp", {})
|
|
|
|
|
|
def test_wrap_opencode_injects_mcp_by_default(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""MCP is included in OPENCODE_CONFIG_CONTENT by default."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
config = json.loads(env["OPENCODE_CONFIG_CONTENT"])
|
|
assert "mcp" in config
|
|
assert config["mcp"]["headroom"] == {
|
|
"type": "local",
|
|
"command": ["headroom", "mcp", "serve"],
|
|
"enabled": True,
|
|
"environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9000"},
|
|
}
|
|
|
|
|
|
def test_wrap_opencode_injects_rtk_into_agents_md(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""RTK instructions are injected into global and project AGENTS.md."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
|
|
project_agents = tmp_path / "AGENTS.md"
|
|
assert global_agents.exists(), "Global AGENTS.md should be created"
|
|
assert project_agents.exists(), "Project AGENTS.md should be created"
|
|
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
|
|
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_unwrap_opencode_removes_rtk_from_agents_md(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""unwrap opencode removes the rtk block that wrap opencode injected into both
|
|
the project and global AGENTS.md — mirroring unwrap_codex / unwrap_copilot."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
|
|
project_agents = tmp_path / "AGENTS.md"
|
|
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
|
|
assert wrap_mod._RTK_MARKER in project_agents.read_text(encoding="utf-8")
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
# Both rtk blocks are gone after unwrap (previously left behind). A file that
|
|
# held only the rtk block is removed entirely by _remove_rtk_instructions, so
|
|
# treat a missing file as "block gone".
|
|
def _rtk_absent(path: Path) -> bool:
|
|
return not path.exists() or wrap_mod._RTK_MARKER not in path.read_text(encoding="utf-8")
|
|
|
|
assert _rtk_absent(global_agents)
|
|
assert _rtk_absent(project_agents)
|
|
|
|
|
|
def test_wrap_opencode_no_project_rtk_only_skips_project_agents_md(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
project_agents = tmp_path / "AGENTS.md"
|
|
project_agents.write_text("# Team instructions\n", encoding="utf-8")
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"opencode",
|
|
"--no-project-rtk",
|
|
"--no-proxy",
|
|
"--port",
|
|
"9000",
|
|
"--no-mcp",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert project_agents.read_text(encoding="utf-8") == "# Team instructions\n"
|
|
global_agents = tmp_path / ".config" / "opencode" / "AGENTS.md"
|
|
assert wrap_mod._RTK_MARKER in global_agents.read_text(encoding="utf-8")
|
|
|
|
|
|
def test_wrap_opencode_idempotent_no_duplicate_block(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Running wrap twice must not duplicate the RTK block in AGENTS.md."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
project_agents = tmp_path / "AGENTS.md"
|
|
content = project_agents.read_text(encoding="utf-8")
|
|
assert content.count(wrap_mod._RTK_MARKER) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Unwrap opencode
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unwrap_opencode_restores_from_backup(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap restores the pre-wrap backup and removes it."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
backup_file = config_file.with_name("opencode.json.headroom-backup")
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
original = '{"model": "openai/gpt-4o"}'
|
|
config_file.write_text(original)
|
|
backup_file.write_text(original)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Restored prior" in result.output
|
|
assert not backup_file.exists()
|
|
assert config_file.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_unwrap_opencode_restores_from_backup_jsonc(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap restores the pre-wrap backup and removes it for jsonc files."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.jsonc"
|
|
backup_file = config_file.with_name("opencode.jsonc.headroom-backup")
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
original = '{\n // User comment\n "model": "openai/gpt-4o"\n}'
|
|
config_file.write_text(original)
|
|
backup_file.write_text(original)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Restored prior" in result.output
|
|
assert not backup_file.exists()
|
|
assert config_file.read_text(encoding="utf-8") == original
|
|
|
|
|
|
def test_unwrap_opencode_strips_blocks_when_no_backup(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap strips Headroom blocks when no backup exists."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
user_content = '{"model": "openai/gpt-4o"}'
|
|
wrapped_content = (
|
|
wrap_mod._PROVIDER_MARKER_START
|
|
+ '\n"provider": {},\n'
|
|
+ wrap_mod._PROVIDER_MARKER_END
|
|
+ "\n"
|
|
+ user_content
|
|
)
|
|
config_file.write_text(wrapped_content)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Removed Headroom block" in result.output
|
|
assert user_content in config_file.read_text(encoding="utf-8")
|
|
assert wrap_mod._PROVIDER_MARKER_START not in config_file.read_text(encoding="utf-8")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases — wrap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_wrap_opencode_preserves_existing_user_providers(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrap merges headroom provider without disturbing user's existing providers."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text('{"provider": {"openai": {"models": {"gpt-4o": {"name": "GPT-4o"}}}}}')
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert "headroom" in config["provider"], "headroom provider not injected"
|
|
assert "openai" in config["provider"], "user's openai provider was removed"
|
|
|
|
|
|
def test_wrap_opencode_port_change_updates_existing_config(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrapping with a different port updates the baseURL in opencode.json."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9001/v1"
|
|
|
|
|
|
def test_wrap_opencode_handles_malformed_config_file(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrap handles a malformed opencode.json by backing it up before overwriting."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
malformed = '{"model": "gpt-4o",}' # trailing comma
|
|
config_file.write_text(malformed)
|
|
backup_file = config_file.with_suffix(".json.headroom-backup")
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert backup_file.exists(), "backup must be created before overwriting"
|
|
assert backup_file.read_text(encoding="utf-8") == malformed, (
|
|
"backup must preserve original byte-for-byte"
|
|
)
|
|
# The config file is now valid JSON with headroom provider.
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert "headroom" in config.get("provider", {})
|
|
|
|
|
|
def test_wrap_opencode_handles_empty_config_file(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrap handles an empty opencode.json file gracefully."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text("")
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1"
|
|
|
|
|
|
def test_wrap_opencode_handles_config_dir_missing(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrap creates the config directory when it doesn't exist."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_dir = tmp_path / ".config" / "opencode"
|
|
assert not config_dir.exists()
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert config_dir.exists()
|
|
assert (config_dir / "opencode.json").exists()
|
|
|
|
|
|
def test_wrap_opencode_rtk_preserves_existing_agents_md(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""RTK injection appends to AGENTS.md without removing existing content."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
existing_content = "# My custom rules\nUse spaces, not tabs."
|
|
(tmp_path / "AGENTS.md").write_text(existing_content)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert existing_content in content
|
|
assert wrap_mod._RTK_MARKER in content
|
|
|
|
|
|
def test_wrap_opencode_no_rtk_leaves_agents_md_untouched(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`--no-rtk` flag leaves existing AGENTS.md untouched."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
existing_content = "# My custom rules\nUse spaces, not tabs."
|
|
(tmp_path / "AGENTS.md").write_text(existing_content)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main, ["wrap", "opencode", "--port", "9000", "--no-rtk", "--no-mcp"]
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
content = (tmp_path / "AGENTS.md").read_text(encoding="utf-8")
|
|
assert content == existing_content, "--no-rtk modified AGENTS.md"
|
|
assert wrap_mod._RTK_MARKER not in content
|
|
|
|
|
|
def test_wrap_opencode_respects_opencode_config_env(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OPENCODE_CONFIG env var overrides the default config path."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
custom_config = tmp_path / "custom" / "config.json"
|
|
monkeypatch.setenv("OPENCODE_CONFIG", str(custom_config))
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert custom_config.exists()
|
|
default_config = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
assert not default_config.exists(), (
|
|
"default config should not be created when OPENCODE_CONFIG is set"
|
|
)
|
|
|
|
|
|
def test_wrap_opencode_headroom_project_from_cwd(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""HEADROOM_PROJECT is set based on the current working directory name."""
|
|
project_dir = tmp_path / "my-project"
|
|
project_dir.mkdir()
|
|
monkeypatch.chdir(project_dir)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
monkeypatch.delenv("HEADROOM_PROJECT", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
assert env.get("HEADROOM_PROJECT") == "my-project"
|
|
|
|
|
|
def test_wrap_opencode_respects_existing_headroom_project(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""User-set HEADROOM_PROJECT env var is preserved, not overridden."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
monkeypatch.setenv("HEADROOM_PROJECT", "user-set-value")
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
assert env["HEADROOM_PROJECT"] == "user-set-value"
|
|
|
|
|
|
def test_wrap_opencode_config_merges_existing_model(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Wrap preserves the user's existing model selection."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text('{"model": "openai/gpt-4o"}')
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
config = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert config["model"] == "openai/gpt-4o"
|
|
assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Edge cases — unwrap
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unwrap_opencode_removes_config_when_only_headroom_content(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap removes the config file entirely when it contained only Headroom content."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
wrapped_content = (
|
|
wrap_mod._PROVIDER_MARKER_START + '\n"provider": {},\n' + wrap_mod._PROVIDER_MARKER_END
|
|
)
|
|
config_file.write_text(wrapped_content)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Removed" in result.output
|
|
assert not config_file.exists()
|
|
|
|
|
|
def test_unwrap_opencode_noop_when_config_missing(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap is a safe no-op when the config file doesn't exist."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "does not exist" in result.output
|
|
|
|
|
|
def test_unwrap_opencode_noop_when_no_headroom_markers(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap is a safe no-op when the config has no Headroom markers."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
config_file.write_text('{"model": "openai/gpt-4o"}')
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "no Headroom wrap markers" in result.output
|
|
assert config_file.read_text(encoding="utf-8").strip() == '{"model": "openai/gpt-4o"}'
|
|
|
|
|
|
def test_wrap_unwrap_rewrap_is_idempotent(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Full wrap-unwrap-rewrap cycle produces consistent results."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
user_config = '{"model": "openai/gpt-4o", "provider": {"openai": {}}}'
|
|
config_file.write_text(user_config)
|
|
|
|
# First wrap
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
# Unwrap
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
# After unwrap, file should match original
|
|
after_unwrap = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert after_unwrap["model"] == "openai/gpt-4o"
|
|
assert "headroom" not in after_unwrap.get("provider", {})
|
|
|
|
# Re-wrap
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
runner.invoke(main, ["wrap", "opencode", "--port", "9001", "--no-mcp"])
|
|
|
|
# After re-wrap, headroom should be back, model unchanged
|
|
after_rewrap = json.loads(config_file.read_text(encoding="utf-8"))
|
|
assert after_rewrap["model"] == "openai/gpt-4o"
|
|
assert "headroom" in after_rewrap.get("provider", {})
|
|
|
|
|
|
def test_unwrap_opencode_restores_backup_and_removes_it(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap removes the backup file after successful restore."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
backup_file = config_file.with_suffix(".json.headroom-backup")
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
original = '{"model": "openai/gpt-4o"}'
|
|
config_file.write_text(original)
|
|
backup_file.write_text(original)
|
|
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Restored prior" in result.output
|
|
assert not backup_file.exists(), "backup file was not cleaned up after restore"
|
|
|
|
|
|
def test_wrap_opencode_no_arguments_is_valid(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""`headroom wrap opencode` with no additional arguments is a valid command."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_launch_tool(**kwargs): # noqa: ANN003
|
|
captured.update(kwargs)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=fake_launch_tool):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["tool_label"] == "OPENCODE"
|
|
assert captured["args"] == ()
|
|
|
|
|
|
def test_wrap_opencode_with_memory_flag(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""--memory flag is accepted and does not crash."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main, ["wrap", "opencode", "--port", "9000", "--memory", "--no-mcp"]
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
def test_wrap_opencode_with_backend_and_anyllm_provider(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""--backend and --anyllm-provider flags are accepted."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main,
|
|
[
|
|
"wrap",
|
|
"opencode",
|
|
"--port",
|
|
"9000",
|
|
"--backend",
|
|
"anyllm",
|
|
"--anyllm-provider",
|
|
"groq",
|
|
"--no-mcp",
|
|
],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
def test_wrap_opencode_with_no_proxy(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""--no-proxy flag skips proxy startup but still configures the tool."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main, ["wrap", "opencode", "--port", "9000", "--no-proxy", "--no-mcp"]
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
def test_wrap_opencode_with_verbose_flag(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""--verbose flag does not crash."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(
|
|
main, ["wrap", "opencode", "--port", "9000", "--verbose", "--no-mcp"]
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
|
|
|
|
def test_wrap_opencode_respects_opencode_home_env(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""OPENCODE_HOME env var controls where AGENTS.md is written."""
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False)
|
|
custom_home = str(tmp_path / "custom-opencode-home")
|
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
monkeypatch.setenv("OPENCODE_HOME", custom_home)
|
|
|
|
with patch.object(wrap_mod.shutil, "which", return_value="opencode"):
|
|
with patch.object(wrap_mod, "_launch_tool", side_effect=SystemExit(0)):
|
|
with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")):
|
|
result = runner.invoke(main, ["wrap", "opencode", "--port", "9000", "--no-mcp"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
agents_md = Path(custom_home) / "AGENTS.md"
|
|
assert agents_md.exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Regression: unwrap must preserve non-ASCII UTF-8 user content (#1126)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_unwrap_opencode_preserves_utf8_user_content(
|
|
runner: CliRunner,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Unwrap strips Headroom blocks but preserves non-ASCII UTF-8 user content (#1126)."""
|
|
monkeypatch.chdir(tmp_path)
|
|
_set_test_home(monkeypatch, tmp_path)
|
|
|
|
config_file = tmp_path / ".config" / "opencode" / "opencode.json"
|
|
config_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# User content with smart quotes and em dashes (non-ASCII UTF-8)
|
|
user_config = {
|
|
"model": "openai/gpt-4o",
|
|
"description": "“smart quotes” and an em dash — here",
|
|
}
|
|
user_json = json.dumps(user_config, ensure_ascii=False)
|
|
|
|
wrapped_content = (
|
|
wrap_mod._PROVIDER_MARKER_START
|
|
+ '\n"provider": {},\n'
|
|
+ wrap_mod._PROVIDER_MARKER_END
|
|
+ "\n"
|
|
+ user_json
|
|
)
|
|
config_file.write_text(wrapped_content, encoding="utf-8")
|
|
|
|
# Mock out OpencodeRegistrar to avoid its own bare-open encoding issue
|
|
# (pre-existing; outside this PR's scope).
|
|
fake_registrar = type("FakeRegistrar", (), {"detect": lambda self: False})()
|
|
with patch.object(wrap_mod, "_stop_local_proxy_for_unwrap", return_value="stopped"):
|
|
with patch("headroom.mcp_registry.OpencodeRegistrar", return_value=fake_registrar):
|
|
result = runner.invoke(main, ["unwrap", "opencode"])
|
|
|
|
assert result.exit_code == 0, result.output
|
|
assert "Removed Headroom block" in result.output
|
|
content = config_file.read_text(encoding="utf-8")
|
|
assert "“smart quotes”" in content
|
|
assert "—" in content
|
|
assert wrap_mod._PROVIDER_MARKER_START not in content
|