diff --git a/headroom/cli/wrap.py b/headroom/cli/wrap.py index cc87ba6e0..966d150ac 100644 --- a/headroom/cli/wrap.py +++ b/headroom/cli/wrap.py @@ -32,7 +32,7 @@ import subprocess import sys import time import urllib.parse -from collections.abc import Callable +from collections.abc import Callable, Mapping from contextlib import contextmanager from functools import wraps from pathlib import Path @@ -3943,6 +3943,20 @@ def _copilot_default_wire_api_for_model(model: str | None) -> str: return _copilot_default_wire_api_for_model_impl(model) +def _build_copilot_native_launch_env( + *, port: int, environ: dict[str, str], project: str | None +) -> tuple[dict[str, str], list[str]]: + from headroom.providers.copilot.wrap import build_native_launch_env + + return build_native_launch_env(port=port, environ=environ, project=project) + + +def _native_api_url_supported(*, environ: Mapping[str, str] | None = None) -> bool | None: + from headroom.providers.copilot.wrap import native_api_url_supported + + return native_api_url_supported(environ=environ) + + def _should_use_copilot_oauth( *, backend: str | None, @@ -4646,6 +4660,7 @@ def _launch_tool( anyllm_provider: str | None = None, region: str | None = None, openai_api_url: str | None = None, + anthropic_api_url: str | None = None, copilot_api_token: str | None = None, copilot_refresh_oauth_token: str | None = None, copilot_api_token_expires_at: float | None = None, @@ -4682,6 +4697,7 @@ def _launch_tool( anyllm_provider=anyllm_provider, region=region, openai_api_url=openai_api_url, + anthropic_api_url=anthropic_api_url, copilot_api_token=copilot_api_token, copilot_refresh_oauth_token=copilot_refresh_oauth_token, copilot_api_token_expires_at=copilot_api_token_expires_at, @@ -5607,6 +5623,14 @@ def _require_copilot_subscription_resolution() -> CopilotSubscriptionTokenResolu ), ) @click.option("--memory", is_flag=True, help="Enable persistent cross-session memory") +@click.option( + "--native", + is_flag=True, + help=( + "Route Copilot's own GitHub-authenticated API through Headroom instead of " + "the single-model BYOK override. Keeps native model aliases and /model switching." + ), +) @click.option("--verbose", "-v", is_flag=True, help="Verbose output") @click.argument("copilot_args", nargs=-1, type=click.UNPROCESSED) def copilot( @@ -5619,6 +5643,7 @@ def copilot( wire_api: str | None, subscription: bool, memory: bool, + native: bool, verbose: bool, copilot_args: tuple[str, ...], ) -> None: @@ -5653,6 +5678,7 @@ def copilot( ) raise SystemExit(1) + explicit_subscription = subscription effective_backend = backend or os.environ.get("HEADROOM_BACKEND") if _check_proxy(port): running_backend = _detect_running_proxy_backend(port) @@ -5663,6 +5689,17 @@ def copilot( ) effective_backend = running_backend or effective_backend + if native: + subscription = True + if provider_type == "anthropic": + raise click.ClickException( + "--native does not use the BYOK provider override; drop --provider-type anthropic." + ) + if wire_api is not None: + raise click.ClickException( + "--native selects the wire per request; drop the BYOK-only --wire-api option." + ) + effective_provider_type = _resolve_copilot_provider_type(effective_backend, provider_type) if subscription: if effective_backend not in (None, "", "anthropic"): @@ -5690,12 +5727,22 @@ def copilot( copilot_api_token_expires_at: float | None = None client_bearer: str | None = None subscription_resolution: CopilotSubscriptionTokenResolution | None = None - if _should_use_copilot_oauth( + anthropic_api_url: str | None = None + use_copilot_oauth = _should_use_copilot_oauth( backend=effective_backend, provider_type=provider_type, env=env, force_subscription=subscription, - ): + ) + # Without a provider key, the old implicit OAuth lane still configured + # Copilot as a one-model BYOK client. Native aliases (and runtime /model + # switches) were then forwarded literally and rejected by GitHub (#1910). + # Explicit --subscription remains on its existing fixed-wire behavior; + # implicit GitHub OAuth uses Copilot's own routing automatically. + if use_copilot_oauth and not explicit_subscription: + native = True + + if use_copilot_oauth: if subscription: subscription_resolution = _require_copilot_subscription_resolution() client_bearer = subscription_resolution.token @@ -5708,7 +5755,35 @@ def copilot( "GITHUB_COPILOT_TOKEN / GITHUB_COPILOT_GITHUB_TOKEN." ) - selected_model = _copilot_model_from_args(copilot_args, env) + if native: + openai_api_url = ( + subscription_resolution.api_url + if subscription_resolution is not None + else resolve_copilot_api_url(client_bearer) + ) + env, env_vars_display = _build_copilot_native_launch_env( + port=port, + environ=env, + project=_project_name_from_cwd(), + ) + env["GITHUB_COPILOT_API_URL"] = openai_api_url + env["OPENAI_TARGET_API_URL"] = openai_api_url + env["ANTHROPIC_TARGET_API_URL"] = openai_api_url + anthropic_api_url = openai_api_url + copilot_proxy_token = client_bearer + if subscription_resolution is not None: + copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token + copilot_api_token_expires_at = subscription_resolution.api_token_expires_at + support = _native_api_url_supported(environ=os.environ) + if support is False: + raise click.ClickException( + "This Copilot CLI build does not reference COPILOT_API_URL; refusing " + "a native launch that could silently bypass Headroom." + ) + if support is None and verbose: + click.echo(" Note: could not verify this Copilot CLI's COPILOT_API_URL support.") + else: + selected_model = _copilot_model_from_args(copilot_args, env) # ``--model auto`` is a Copilot-internal routing token that the BYOK # API rejects with ``400 The requested model is not supported``. In @@ -5716,7 +5791,7 @@ def copilot( # Copilot's own native auto-selection works fine — we just need to # strip the ``--model auto`` flag before launch so Copilot doesn't # forward it to the provider endpoint. - if _is_auto_model(selected_model): + if not native and _is_auto_model(selected_model): copilot_args = _strip_auto_model_args(copilot_args) selected_model = None click.echo( @@ -5725,57 +5800,58 @@ def copilot( "automatic model selection." ) - env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API") - effective_wire_api = wire_api or ( - env_wire_api - if env_wire_api in {"completions", "responses"} - else _copilot_default_wire_api_for_model(selected_model) - ) - env["COPILOT_PROVIDER_TYPE"] = "openai" - # Per-project savings: the Copilot CLI cannot send custom headers, so - # the project rides as a /p/ base-URL prefix the proxy strips. - env["COPILOT_PROVIDER_BASE_URL"] = _with_project_prefix( - f"http://127.0.0.1:{port}/v1", _project_name_from_cwd() - ) - env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api - env["COPILOT_PROVIDER_BEARER_TOKEN"] = client_bearer - env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] = "false" - env.pop("COPILOT_PROVIDER_API_KEY", None) - # Hand the exact token we resolved (and, for --subscription, validated - # against GitHub) to the proxy explicitly via copilot_proxy_token below. - # The proxy pins it as GITHUB_COPILOT_API_TOKEN, so upstream auth is - # deterministic instead of the proxy re-running unvalidated discovery - # (read_cached_oauth_token returns the *first* candidate, which may not - # be the one the wrapper approved → environment-dependent 401s). Passing - # it as a launch argument — rather than mutating this process's global - # os.environ — keeps the token off shared state and out of unrelated - # code paths. - copilot_proxy_token = client_bearer - if subscription_resolution is not None: - copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token - copilot_api_token_expires_at = subscription_resolution.api_token_expires_at - env_vars_display = [ - "COPILOT_PROVIDER_TYPE=openai", - f"COPILOT_PROVIDER_BASE_URL={env['COPILOT_PROVIDER_BASE_URL']}", - f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}", - ( - "COPILOT_AUTH_MODE=github-subscription-experimental" - if subscription - else "COPILOT_AUTH_MODE=github-oauth" - ), - ] - # Non-subscription OAuth keeps upstream's generic-host policy from - # #610. Subscription mode can use the endpoint returned by the Copilot - # token exchange, which is how Business accounts advertise their API - # host without requiring users to configure it manually. - openai_api_url = ( - subscription_resolution.api_url - if subscription_resolution is not None - else resolve_copilot_api_url(client_bearer) - ) - env["GITHUB_COPILOT_API_URL"] = openai_api_url - env["OPENAI_TARGET_API_URL"] = openai_api_url - env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}") + if not native: + env_wire_api = env.get("COPILOT_PROVIDER_WIRE_API") + effective_wire_api = wire_api or ( + env_wire_api + if env_wire_api in {"completions", "responses"} + else _copilot_default_wire_api_for_model(selected_model) + ) + env["COPILOT_PROVIDER_TYPE"] = "openai" + # Per-project savings: the Copilot CLI cannot send custom headers, so + # the project rides as a /p/ base-URL prefix the proxy strips. + env["COPILOT_PROVIDER_BASE_URL"] = _with_project_prefix( + f"http://127.0.0.1:{port}/v1", _project_name_from_cwd() + ) + env["COPILOT_PROVIDER_WIRE_API"] = effective_wire_api + env["COPILOT_PROVIDER_BEARER_TOKEN"] = client_bearer + env["GITHUB_COPILOT_USE_TOKEN_EXCHANGE"] = "false" + env.pop("COPILOT_PROVIDER_API_KEY", None) + # Hand the exact token we resolved (and, for --subscription, validated + # against GitHub) to the proxy explicitly via copilot_proxy_token below. + # The proxy pins it as GITHUB_COPILOT_API_TOKEN, so upstream auth is + # deterministic instead of the proxy re-running unvalidated discovery + # (read_cached_oauth_token returns the *first* candidate, which may not + # be the one the wrapper approved → environment-dependent 401s). Passing + # it as a launch argument — rather than mutating this process's global + # os.environ — keeps the token off shared state and out of unrelated + # code paths. + copilot_proxy_token = client_bearer + if subscription_resolution is not None: + copilot_refresh_oauth_token = subscription_resolution.refresh_oauth_token + copilot_api_token_expires_at = subscription_resolution.api_token_expires_at + env_vars_display = [ + "COPILOT_PROVIDER_TYPE=openai", + f"COPILOT_PROVIDER_BASE_URL={env['COPILOT_PROVIDER_BASE_URL']}", + f"COPILOT_PROVIDER_WIRE_API={effective_wire_api}", + ( + "COPILOT_AUTH_MODE=github-subscription-experimental" + if subscription + else "COPILOT_AUTH_MODE=github-oauth" + ), + ] + # Non-subscription OAuth keeps upstream's generic-host policy from + # #610. Subscription mode can use the endpoint returned by the Copilot + # token exchange, which is how Business accounts advertise their API + # host without requiring users to configure it manually. + openai_api_url = ( + subscription_resolution.api_url + if subscription_resolution is not None + else resolve_copilot_api_url(client_bearer) + ) + env["GITHUB_COPILOT_API_URL"] = openai_api_url + env["OPENAI_TARGET_API_URL"] = openai_api_url + env_vars_display.append(f"COPILOT_PROVIDER_API_URL={openai_api_url}") else: env, env_vars_display = _build_copilot_launch_env( port=port, @@ -5798,7 +5874,7 @@ def copilot( ) raise SystemExit(1) - if not subscription and not _copilot_model_configured(copilot_args, env): + if not subscription and not native and not _copilot_model_configured(copilot_args, env): # Distinguish between "--model auto" (wrong model for BYOK) and # genuinely missing model (no --model flag at all). raw_model = _copilot_model_from_args(copilot_args, env) @@ -5835,6 +5911,7 @@ def copilot( anyllm_provider=anyllm_provider, region=region, openai_api_url=openai_api_url, + anthropic_api_url=anthropic_api_url, copilot_api_token=copilot_proxy_token, copilot_refresh_oauth_token=copilot_refresh_oauth_token, copilot_api_token_expires_at=copilot_api_token_expires_at, diff --git a/headroom/providers/copilot/wrap.py b/headroom/providers/copilot/wrap.py index db1610a3b..e73ec3110 100644 --- a/headroom/providers/copilot/wrap.py +++ b/headroom/providers/copilot/wrap.py @@ -156,6 +156,73 @@ def provider_key_source(provider_type: str) -> str: return "ANTHROPIC_API_KEY" if provider_type == "anthropic" else "OPENAI_API_KEY" +COPILOT_NATIVE_API_URL_ENV = "COPILOT_API_URL" + +# Any survivor keeps Copilot in its single-model BYOK lane, defeating native +# model routing while making the launch look superficially successful. +COPILOT_BYOK_ENV_VARS: tuple[str, ...] = ( + "COPILOT_PROVIDER_BASE_URL", + "COPILOT_PROVIDER_TYPE", + "COPILOT_PROVIDER_API_KEY", + "COPILOT_PROVIDER_BEARER_TOKEN", + "COPILOT_PROVIDER_WIRE_API", + "COPILOT_PROVIDER_TRANSPORT", + "COPILOT_PROVIDER_AZURE_API_VERSION", + "COPILOT_PROVIDER_MODEL_ID", + "COPILOT_PROVIDER_WIRE_MODEL", + "COPILOT_PROVIDER_MODEL_LIMITS_ID", + "COPILOT_PROVIDER_MAX_PROMPT_TOKENS", + "COPILOT_PROVIDER_MAX_OUTPUT_TOKENS", + "COPILOT_PROVIDER_HEADERS", +) + + +def build_native_launch_env( + *, + port: int, + environ: Mapping[str, str] | None = None, + project: str | None = None, +) -> tuple[dict[str, str], list[str]]: + """Redirect Copilot's native API surface through Headroom, not BYOK.""" + env = dict(environ if environ is not None else os.environ) + base_url = with_project_prefix(f"http://127.0.0.1:{port}", project) + env[COPILOT_NATIVE_API_URL_ENV] = base_url + for variable in COPILOT_BYOK_ENV_VARS: + env.pop(variable, None) + return env, [ + f"{COPILOT_NATIVE_API_URL_ENV}={base_url}", + "COPILOT_AUTH_MODE=github-native", + ] + + +def native_api_url_supported(*, environ: Mapping[str, str] | None = None) -> bool | None: + """Best-effort tri-state probe for the CLI's native API URL override.""" + env = environ if environ is not None else os.environ + local = env.get("LOCALAPPDATA") or env.get("HOME") or os.path.expanduser("~") + roots = ( + os.path.join(local, "copilot", "pkg"), + os.path.join(os.path.expanduser("~"), ".local", "share", "copilot", "pkg"), + ) + found_bundle = False + for root in roots: + if not os.path.isdir(root): + continue + for dirpath, _dirnames, filenames in os.walk(root): + if "app.js" not in filenames: + continue + found_bundle = True + try: + with open( + os.path.join(dirpath, "app.js"), encoding="utf-8", errors="replace" + ) as bundle: + while chunk := bundle.read(1 << 20): + if COPILOT_NATIVE_API_URL_ENV in chunk: + return True + except OSError: + continue + return False if found_bundle else None + + def build_launch_env( *, port: int, diff --git a/tests/test_cli/test_wrap_copilot.py b/tests/test_cli/test_wrap_copilot.py index 98f261f09..9772b3bdb 100644 --- a/tests/test_cli/test_wrap_copilot.py +++ b/tests/test_cli/test_wrap_copilot.py @@ -266,17 +266,16 @@ def test_wrap_copilot_prefers_existing_oauth_session( assert result.exit_code == 0, result.output env = captured["env"] assert isinstance(env, dict) - assert env["COPILOT_PROVIDER_TYPE"] == "openai" - assert env["COPILOT_PROVIDER_BASE_URL"] == ( - f"http://127.0.0.1:8787{_expected_project_prefix()}/v1" - ) - assert env["COPILOT_PROVIDER_WIRE_API"] == "completions" - assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-existing" + assert env["COPILOT_API_URL"] == f"http://127.0.0.1:8787{_expected_project_prefix()}" + assert "COPILOT_PROVIDER_TYPE" not in env + assert "COPILOT_PROVIDER_BASE_URL" not in env + assert "COPILOT_PROVIDER_WIRE_API" not in env + assert "COPILOT_PROVIDER_BEARER_TOKEN" not in env assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL assert "COPILOT_PROVIDER_API_KEY" not in env assert captured["openai_api_url"] == DEFAULT_API_URL - assert f"COPILOT_PROVIDER_API_URL={DEFAULT_API_URL}" in captured["env_vars_display"] + assert "COPILOT_AUTH_MODE=github-native" in captured["env_vars_display"] @pytest.mark.parametrize( @@ -293,7 +292,7 @@ def test_wrap_copilot_oauth_defaults_wire_api_for_selected_model( model: str, expected_wire_api: str, ) -> None: - """OAuth sessions use the same model-aware wire API default as subscriptions.""" + """Implicit OAuth leaves wire selection to Copilot's native router.""" _wrap_cli, main = wrap_modules _clear_copilot_env(monkeypatch) captured: dict[str, object] = {} @@ -315,8 +314,8 @@ def test_wrap_copilot_oauth_defaults_wire_api_for_selected_model( assert result.exit_code == 0, result.output env = captured["env"] assert isinstance(env, dict) - assert env["COPILOT_PROVIDER_WIRE_API"] == expected_wire_api - assert f"COPILOT_PROVIDER_WIRE_API={expected_wire_api}" in captured["env_vars_display"] + assert "COPILOT_PROVIDER_WIRE_API" not in env + assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787") @pytest.mark.parametrize("wire_api", ["completions", "responses"]) @@ -348,7 +347,8 @@ def test_wrap_copilot_oauth_honors_existing_wire_api( assert result.exit_code == 0, result.output env = captured["env"] assert isinstance(env, dict) - assert env["COPILOT_PROVIDER_WIRE_API"] == wire_api + assert "COPILOT_PROVIDER_WIRE_API" not in env + assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787") def test_wrap_copilot_subscription_uses_github_auth_without_provider_key( @@ -869,7 +869,8 @@ def test_wrap_copilot_oauth_keeps_generic_endpoint_when_account_advertised( assert result.exit_code == 0, result.output env = captured["env"] assert isinstance(env, dict) - assert env["COPILOT_PROVIDER_BEARER_TOKEN"] == "gho-oauth" + assert "COPILOT_PROVIDER_BEARER_TOKEN" not in env + assert env["COPILOT_API_URL"].startswith("http://127.0.0.1:8787") assert captured["openai_api_url"] == DEFAULT_API_URL assert env["OPENAI_TARGET_API_URL"] == DEFAULT_API_URL assert env["GITHUB_COPILOT_API_URL"] == DEFAULT_API_URL diff --git a/tests/test_copilot_native_mode.py b/tests/test_copilot_native_mode.py new file mode 100644 index 000000000..72a7d7f77 --- /dev/null +++ b/tests/test_copilot_native_mode.py @@ -0,0 +1,148 @@ +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