mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
GitHub Copilot Enterprise/Business users without a BYOK provider key
were routed through Copilot CLI's single-model provider override. Native
model aliases and runtime `/model` switches were therefore forwarded
literally to the override and rejected with `400 model not supported`.
This change routes implicit GitHub OAuth through Copilot's native API
surface while retaining explicit subscription and provider-key behavior.
Closes #1910
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Added explicit `--native` routing and made it automatic for implicit
GitHub OAuth without BYOK.
- Clears every Copilot BYOK variable before native launch.
- Routes both OpenAI and Anthropic protocol targets through the resolved
tenant Copilot host.
- Preserves Enterprise/Business native aliases and runtime model
switching.
- Rejects BYOK-only options when native routing is selected.
- Refuses known Copilot bundles that do not reference `COPILOT_API_URL`,
avoiding silent proxy bypass.
- Preserves explicit `--subscription` and provider-key BYOK semantics.
- Added coverage for unreadable and unverifiable Copilot CLI bundles.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
884 passed, 4 skipped in 103.11s
ruff check .: All checks passed
ruff format --check .: 1412 files already formatted
mypy headroom/providers/copilot/wrap.py headroom/cli/wrap.py:
Success: no issues found in 2 source files
```
Exact-head CI is entirely green on
`0aca48c096`.
## Real Behavior Proof
- Environment: macOS arm64/Python 3.13 locally; GitHub-hosted macOS and
Ubuntu native-wrap jobs.
- Exact command / steps: invoke `headroom wrap copilot` with implicit
OAuth and an Enterprise model alias; inspect the captured child/proxy
environment and resolved target URLs; exercise explicit native conflicts
and bundle-support probes.
- Observed result: native launch uses `COPILOT_API_URL`, clears all BYOK
state, and points both protocol targets at the tenant host. Native-wrap
jobs are green on macOS and Ubuntu for the refreshed head.
- Not tested: live request against a real Enterprise tenant; the
repository has no organization Enterprise credential available to CI.
## Runtime Rollout Safety
- Rollout-managed feature(s): implicit native Copilot routing for GitHub
OAuth sessions without BYOK.
- Minimum rollout channel: normal patch release.
- Stable/default behavior changed: implicit OAuth now uses native
routing; explicit subscription and BYOK paths are unchanged.
- Kill switch / disable path: use an explicit supported provider-key
BYOK configuration; native mode also fails closed when CLI support is
known absent.
- Unsafe override required: none.
- Qualification impact: native-wrap macOS/Ubuntu, Docker wrapper, full
Python matrix, and Copilot focused suites must pass.
- Rollback path: human revert of this PR restores the fixed-wire OAuth
behavior; no configuration migration is persisted.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation — CLI help
and inline routing documentation; no separate guide required
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
## Screenshots (if applicable)
Not applicable; CLI routing change.
## Additional Notes
Human review only. No merge or auto-merge is configured. Refreshed from
main after #2996; the MCP cap `mcp>=1.28.1,<2.0.0` is preserved.
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
from __future__ import annotations
|
|
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
|
|
from headroom.providers.copilot.wrap import (
|
|
COPILOT_BYOK_ENV_VARS,
|
|
COPILOT_NATIVE_API_URL_ENV,
|
|
build_launch_env,
|
|
build_native_launch_env,
|
|
native_api_url_supported,
|
|
)
|
|
|
|
|
|
def test_native_env_redirects_api_and_clears_all_byok_state() -> None:
|
|
seeded = dict.fromkeys(COPILOT_BYOK_ENV_VARS, "stale")
|
|
seeded["UNRELATED"] = "preserved"
|
|
env, _ = build_native_launch_env(port=8890, environ=seeded, project="repo name")
|
|
|
|
assert env[COPILOT_NATIVE_API_URL_ENV] == "http://127.0.0.1:8890/p/repo%20name"
|
|
assert env["UNRELATED"] == "preserved"
|
|
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
|
|
|
|
|
def test_byok_builder_remains_disjoint_from_native_mode() -> None:
|
|
env, _ = build_launch_env(
|
|
port=8787,
|
|
provider_type="openai",
|
|
wire_api="responses",
|
|
environ={},
|
|
)
|
|
assert env["COPILOT_PROVIDER_BASE_URL"] == "http://127.0.0.1:8787/v1"
|
|
assert env["COPILOT_PROVIDER_WIRE_API"] == "responses"
|
|
assert COPILOT_NATIVE_API_URL_ENV not in env
|
|
|
|
|
|
def test_native_support_probe_distinguishes_unknown_and_unsupported(tmp_path) -> None:
|
|
local = tmp_path / "local"
|
|
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is None
|
|
|
|
bundle = local / "copilot" / "pkg" / "platform" / "1.0" / "app.js"
|
|
bundle.parent.mkdir(parents=True)
|
|
bundle.write_text("no override here", encoding="utf-8")
|
|
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is False
|
|
|
|
bundle.write_text("process.env.COPILOT_API_URL", encoding="utf-8")
|
|
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is True
|
|
|
|
|
|
def test_native_support_probe_skips_unreadable_bundle(monkeypatch, tmp_path) -> None:
|
|
local = tmp_path / "local"
|
|
bundle = local / "copilot" / "pkg" / "platform" / "1.0" / "app.js"
|
|
bundle.parent.mkdir(parents=True)
|
|
bundle.write_text("process.env.COPILOT_API_URL", encoding="utf-8")
|
|
|
|
def _unreadable(*_args, **_kwargs):
|
|
raise OSError("synthetic unreadable bundle")
|
|
|
|
monkeypatch.setattr("builtins.open", _unreadable)
|
|
assert native_api_url_supported(environ={"LOCALAPPDATA": str(local)}) is False
|
|
|
|
|
|
def _invoke_native(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
extra: list[str] | None = None,
|
|
*,
|
|
support: bool | None = True,
|
|
):
|
|
from headroom.cli import wrap as wrap_mod
|
|
from headroom.cli.main import main
|
|
|
|
captured: dict[str, object] = {}
|
|
|
|
class Resolution:
|
|
token = "copilot-token"
|
|
api_url = "https://api.business.githubcopilot.com"
|
|
refresh_oauth_token = "refresh-token"
|
|
api_token_expires_at = 123.0
|
|
|
|
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/copilot")
|
|
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: False)
|
|
monkeypatch.setattr(wrap_mod, "_require_copilot_subscription_resolution", lambda: Resolution())
|
|
monkeypatch.setattr(wrap_mod, "_native_api_url_supported", lambda **_kwargs: support)
|
|
monkeypatch.setattr(wrap_mod, "_launch_tool", lambda **kwargs: captured.update(kwargs))
|
|
result = CliRunner().invoke(
|
|
main,
|
|
["wrap", "copilot", "--native", "--port", "8890", *(extra or [])],
|
|
)
|
|
return result, captured
|
|
|
|
|
|
def test_implicit_oauth_uses_native_routing_without_flag(monkeypatch) -> None:
|
|
from headroom.cli import wrap as wrap_mod
|
|
from headroom.cli.main import main
|
|
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(wrap_mod.shutil, "which", lambda _name: "/usr/bin/copilot")
|
|
monkeypatch.setattr(wrap_mod, "_check_proxy", lambda _port: False)
|
|
monkeypatch.setattr(wrap_mod, "has_oauth_auth", lambda: True)
|
|
monkeypatch.setattr(wrap_mod, "resolve_client_bearer_token", lambda: "oauth-token")
|
|
monkeypatch.setattr(
|
|
wrap_mod, "resolve_copilot_api_url", lambda _token: "https://api.githubcopilot.com"
|
|
)
|
|
monkeypatch.setattr(wrap_mod, "_native_api_url_supported", lambda **_kwargs: True)
|
|
monkeypatch.setattr(wrap_mod, "_launch_tool", lambda **kwargs: captured.update(kwargs))
|
|
|
|
result = CliRunner().invoke(
|
|
main,
|
|
["wrap", "copilot", "--port", "8890", "--", "--model", "claude-sonnet-5"],
|
|
)
|
|
|
|
assert result.exit_code == 0, result.output
|
|
env = captured["env"]
|
|
assert isinstance(env, dict)
|
|
assert COPILOT_NATIVE_API_URL_ENV in env
|
|
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
|
|
|
|
|
def test_native_cli_routes_both_protocols_to_tenant_host(monkeypatch) -> None:
|
|
result, captured = _invoke_native(monkeypatch)
|
|
assert result.exit_code == 0, result.output
|
|
assert captured["openai_api_url"] == "https://api.business.githubcopilot.com"
|
|
assert captured["anthropic_api_url"] == "https://api.business.githubcopilot.com"
|
|
env = captured["env"]
|
|
assert isinstance(env, dict)
|
|
assert COPILOT_NATIVE_API_URL_ENV in env
|
|
assert not any(variable in env for variable in COPILOT_BYOK_ENV_VARS)
|
|
|
|
|
|
@pytest.mark.parametrize("extra", [["--wire-api", "responses"], ["--provider-type", "anthropic"]])
|
|
def test_native_cli_rejects_byok_only_options(monkeypatch, extra) -> None:
|
|
result, captured = _invoke_native(monkeypatch, extra)
|
|
assert result.exit_code != 0
|
|
assert not captured
|
|
|
|
|
|
def test_native_cli_refuses_known_unsupported_bundle(monkeypatch) -> None:
|
|
result, captured = _invoke_native(monkeypatch, support=False)
|
|
assert result.exit_code != 0
|
|
assert "COPILOT_API_URL" in result.output
|
|
assert not captured
|
|
|
|
|
|
def test_native_cli_reports_unknown_support_in_verbose_mode(monkeypatch) -> None:
|
|
result, captured = _invoke_native(monkeypatch, ["--verbose"], support=None)
|
|
assert result.exit_code == 0, result.output
|
|
assert "could not verify" in result.output
|
|
assert captured
|