feat(opencode): support Copilot subscription backend for headroom models (#2441) (#2445)

## 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.
This commit is contained in:
Rod Boev 2026-07-20 14:02:14 -04:00 committed by GitHub
parent 4aac068814
commit 9089e7f7d3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 343 additions and 31 deletions

View file

@ -58,6 +58,12 @@ from headroom.agent_savings import (
apply_agent_savings_env_defaults,
)
from headroom.copilot_auth import (
_API_TOKEN_ENV_VARS,
_API_TOKEN_EXPIRES_AT_ENV_VAR,
_COPILOT_OAUTH_TOKEN_ENV_VARS,
_GENERIC_GITHUB_TOKEN_ENV_VARS,
_REFRESH_OAUTH_TOKEN_ENV_VAR,
CopilotSubscriptionTokenResolution,
has_oauth_auth,
resolve_client_bearer_token,
resolve_copilot_api_url,
@ -167,8 +173,15 @@ from .main import main
_COPILOT_PROXY_SEED_ENV_VARS = (
"GITHUB_COPILOT_API_TOKEN",
"GITHUB_COPILOT_REFRESH_OAUTH_TOKEN",
"GITHUB_COPILOT_API_TOKEN_EXPIRES_AT",
_REFRESH_OAUTH_TOKEN_ENV_VAR,
_API_TOKEN_EXPIRES_AT_ENV_VAR,
)
_COPILOT_SUBSCRIPTION_LAUNCH_SECRET_ENV_VARS = (
*_API_TOKEN_ENV_VARS,
_REFRESH_OAUTH_TOKEN_ENV_VAR,
_API_TOKEN_EXPIRES_AT_ENV_VAR,
*_COPILOT_OAUTH_TOKEN_ENV_VARS,
*_GENERIC_GITHUB_TOKEN_ENV_VARS,
)
@ -177,6 +190,11 @@ def _scrub_copilot_proxy_seed_env(env: dict[str, str]) -> None:
env.pop(key, None)
def _scrub_copilot_subscription_launch_env(env: dict[str, str]) -> None:
for key in _COPILOT_SUBSCRIPTION_LAUNCH_SECRET_ENV_VARS:
env.pop(key, None)
def _read_text(path: Path) -> str:
"""Read a text file as UTF-8, falling back to the system locale encoding."""
return fsutil.read_text(path)
@ -3982,8 +4000,10 @@ def _ensure_proxy(
) -> tuple[subprocess.Popen | None, int]:
"""Start or verify proxy. Returns (process_handle, actual_port)."""
helpers = _live_wrap_module()
copilot_subscription_seed_requested = bool(copilot_refresh_oauth_token) or (
copilot_api_token_expires_at is not None
copilot_subscription_seed_requested = (
bool(copilot_api_token)
or bool(copilot_refresh_oauth_token)
or copilot_api_token_expires_at is not None
)
# --no-proxy reuses an already-running proxy, so backend/region/provider
# flags (which only apply when we start one) would be silently dropped.
@ -3999,7 +4019,7 @@ def _ensure_proxy(
)
if isolated_copilot_subscription_proxy:
click.echo(
" Copilot subscription refresh seeds are session-specific; "
" Copilot subscription seeds are session-specific; "
"starting a dedicated local proxy instance for this wrap session."
)
if not isolated_copilot_subscription_proxy and manifest is not None:
@ -5401,6 +5421,17 @@ def unwrap_claude(
# =============================================================================
def _require_copilot_subscription_resolution() -> CopilotSubscriptionTokenResolution:
resolution = resolve_subscription_bearer_token_details()
if resolution is None:
raise click.ClickException(
"GitHub Copilot subscription mode requires a reusable GitHub/Copilot bearer "
"token, but none could be resolved. Run `headroom copilot-auth login` first, or set "
"GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN."
)
return resolution
@wrap.command(context_settings={"ignore_unknown_options": True})
@_rtk_option
@click.option(
@ -5543,7 +5574,8 @@ def copilot(
copilot_proxy_token: str | None = None
copilot_refresh_oauth_token: str | None = None
copilot_api_token_expires_at: float | None = None
subscription_resolution = None
client_bearer: str | None = None
subscription_resolution: CopilotSubscriptionTokenResolution | None = None
if _should_use_copilot_oauth(
backend=effective_backend,
provider_type=provider_type,
@ -5551,10 +5583,8 @@ def copilot(
force_subscription=subscription,
):
if subscription:
subscription_resolution = resolve_subscription_bearer_token_details()
client_bearer = (
subscription_resolution.token if subscription_resolution is not None else None
)
subscription_resolution = _require_copilot_subscription_resolution()
client_bearer = subscription_resolution.token
else:
client_bearer = resolve_client_bearer_token()
if not client_bearer:
@ -7646,6 +7676,11 @@ def openclaw(
help="Enable code graph indexing via codebase-memory-mcp (optional)",
)
@click.option("--no-proxy", is_flag=True, help="Skip proxy startup (use existing proxy)")
@click.option(
"--copilot-subscription",
is_flag=True,
help="Route headroom/* models through the authenticated GitHub Copilot subscription",
)
@click.option("--learn", is_flag=True, help="Enable live traffic learning")
@click.option("--memory", is_flag=True, help="Enable persistent cross-session memory")
@click.option(
@ -7664,6 +7699,7 @@ def opencode(
no_serena: bool,
code_graph: bool,
no_proxy: bool,
copilot_subscription: bool,
learn: bool,
memory: bool,
backend: str | None,
@ -7690,7 +7726,28 @@ def opencode(
headroom wrap opencode --no-serena # Skip Serena MCP registration
headroom wrap opencode --port 9999 # Custom proxy port
headroom wrap opencode --backend anyllm --anyllm-provider groq
headroom wrap opencode --copilot-subscription # Use a GitHub Copilot subscription
"""
subscription_resolution = None
if copilot_subscription:
effective_backend = backend or os.environ.get("HEADROOM_BACKEND")
if effective_backend not in (None, "", "anthropic"):
raise click.ClickException(
"--copilot-subscription cannot be combined with translated backends "
"such as anyllm or litellm-*; use the anthropic backend."
)
if no_proxy:
raise click.ClickException(
"--copilot-subscription cannot be combined with --no-proxy because "
"it requires a private seeded proxy."
)
if prepare_only:
raise click.ClickException(
"--copilot-subscription cannot be combined with --prepare-only because "
"it requires a running private seeded proxy."
)
subscription_resolution = _require_copilot_subscription_resolution()
# Snapshot OpenCode config.json BEFORE any wrap-time mutation so
# `headroom unwrap opencode` can restore the user's pre-wrap state.
_opencode_config_file, _opencode_backup_file = opencode_config_paths()
@ -7770,33 +7827,44 @@ def opencode(
backend=backend,
anyllm_provider=anyllm_provider,
region=region,
openai_api_url=(subscription_resolution.api_url if subscription_resolution else None),
copilot_api_token=(subscription_resolution.token if subscription_resolution else None),
copilot_refresh_oauth_token=(
subscription_resolution.refresh_oauth_token if subscription_resolution else None
),
copilot_api_token_expires_at=(
subscription_resolution.api_token_expires_at if subscription_resolution else None
),
)
# If the proxy fell back to a different port, move our marker so
# cleanup tracking stays accurate and update MCP config.
if actual_port != port:
_unregister_proxy_client(port)
_register_proxy_client(actual_port)
if not no_mcp:
from headroom.mcp_registry import OpencodeRegistrar
try:
# If the proxy fell back to a different port, move our marker so
# cleanup tracking stays accurate and update MCP config.
if actual_port != port:
_unregister_proxy_client(port)
_register_proxy_client(actual_port)
if not no_mcp:
from headroom.mcp_registry import OpencodeRegistrar
_setup_headroom_mcp(OpencodeRegistrar(), actual_port, verbose=verbose, force=True)
_setup_headroom_mcp(OpencodeRegistrar(), actual_port, verbose=verbose, force=True)
env, env_vars_display = _build_opencode_launch_env(
actual_port, os.environ, project=_project_name_from_cwd(), include_mcp=not no_mcp
)
# Inject Headroom provider into OpenCode config so traffic routes through proxy.
inject_opencode_provider_config(actual_port)
if memory:
mem_dir = Path.cwd() / ".headroom"
_inject_memory_mcp_config(
os.environ.get("USER", os.environ.get("USERNAME", "default")),
launch_environ = os.environ.copy()
if subscription_resolution is not None:
_scrub_copilot_subscription_launch_env(launch_environ)
env, env_vars_display = _build_opencode_launch_env(
actual_port, launch_environ, project=_project_name_from_cwd(), include_mcp=not no_mcp
)
# Proxy already started by _ensure_proxy above; tell _launch_tool to
# skip duplicate startup.
try:
# Inject Headroom provider into OpenCode config so traffic routes through proxy.
inject_opencode_provider_config(actual_port)
if memory:
mem_dir = Path.cwd() / ".headroom"
_inject_memory_mcp_config(
os.environ.get("USER", os.environ.get("USERNAME", "default")),
)
# Proxy already started by _ensure_proxy above; tell _launch_tool to
# skip duplicate startup.
_launch_tool(
binary=opencode_bin,
args=opencode_args,

View file

@ -11,6 +11,7 @@ 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)
@ -32,11 +33,225 @@ def _set_test_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
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,

View file

@ -417,6 +417,35 @@ def test_ensure_proxy_starts_isolated_ephemeral_proxy_for_copilot_subscription_s
assert calls[1][2]["copilot_api_token_expires_at"] == 456.5
def test_ensure_proxy_isolates_copilot_subscription_seed_with_api_token_only(monkeypatch) -> None:
calls: list[object] = []
monkeypatch.setattr(wrap_cli, "_find_persistent_manifest", lambda port: None)
monkeypatch.setattr(wrap_cli, "_check_proxy", lambda port: port == 8787)
monkeypatch.setattr(
wrap_cli,
"_find_available_port",
lambda start_port, **kw: calls.append(("find_port", start_port)) or 8788,
)
monkeypatch.setattr(
wrap_cli,
"_start_proxy",
lambda *args, **kwargs: calls.append(("start", args, kwargs)),
)
proc, actual_port = wrap_cli._ensure_proxy(
8787,
False,
copilot_api_token="direct-token-only",
)
assert proc is None
assert actual_port == 8788
assert calls[0] == ("find_port", 8788)
assert calls[1][0] == "start"
assert calls[1][2]["copilot_api_token"] == "direct-token-only"
def test_ensure_proxy_starts_isolated_ephemeral_proxy_when_subscription_seed_targets_persistent_port(
monkeypatch,
) -> None: