diff --git a/headroom/cli/install.py b/headroom/cli/install.py index fd61c2bea..a8cd274ca 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -2,9 +2,11 @@ from __future__ import annotations +import os import shutil import subprocess import sys +from collections.abc import Mapping from copy import deepcopy from dataclasses import dataclass @@ -315,6 +317,40 @@ def _build_deployment_manifest( return manifest +# Upstream-routing overrides the interactive `headroom proxy` reads from the +# environment (via resolve_api_overrides), but a supervised runner starts from a +# bare environment, so these never reach the persistent proxy unless captured +# into the manifest. Without this, `install apply` with e.g. +# ANTHROPIC_TARGET_API_URL exported silently routes to the default provider +# endpoint instead of the user's gateway (#2240). Only URL overrides are +# captured; the *_TARGET_API_HEADERS vars can carry bearer tokens and are left +# to explicit `--env` so a secret is never persisted to the manifest implicitly. +_PASSTHROUGH_URL_ENV_VARS = ( + "ANTHROPIC_TARGET_API_URL", + "ANTHROPIC_FOUNDRY_BASE_URL", + "OPENAI_TARGET_API_URL", + "GEMINI_TARGET_API_URL", + "CLOUDCODE_TARGET_API_URL", + "VERTEX_TARGET_API_URL", + "BEDROCK_TARGET_API_URL", +) + + +def _capture_passthrough_env(environ: Mapping[str, str]) -> dict[str, str]: + """Return the upstream-routing overrides present in ``environ``. + + An empty or unset value is skipped so it cannot shadow an auto-derived + default. Explicit ``--env`` values are meant to win over these, so callers + should merge the returned dict *under* the parsed ``--env`` map. + """ + captured: dict[str, str] = {} + for name in _PASSTHROUGH_URL_ENV_VARS: + value = environ.get(name) + if value: + captured[name] = value + return captured + + def _apply_manifest(manifest: DeploymentManifest) -> None: try: existing = load_manifest(manifest.profile) @@ -525,6 +561,11 @@ def install_apply( key, _, value = item.partition("=") parsed_env[key] = value + # Auto-carry upstream-routing overrides from the current environment so a + # supervised runner forwards to the same gateway the interactive proxy would + # (#2240). Explicit --env wins, so merge the captured vars underneath. + combined_env = {**_capture_passthrough_env(os.environ), **parsed_env} + manifest = _build_deployment_manifest( profile=profile, preset=preset, @@ -546,7 +587,7 @@ def install_apply( intercept_tool_results=intercept_tool_results, protect_tool_results=protect_tool_results, bedrock_profile=bedrock_profile, - extra_env=parsed_env, + extra_env=combined_env, ) _apply_manifest(manifest) diff --git a/tests/test_cli/test_install_cli.py b/tests/test_cli/test_install_cli.py index 46590c132..691f12143 100644 --- a/tests/test_cli/test_install_cli.py +++ b/tests/test_cli/test_install_cli.py @@ -108,6 +108,78 @@ def test_install_apply_help_lists_no_http2() -> None: assert "--no-http2" in result.output +def test_capture_passthrough_env_skips_empty_and_unrelated() -> None: + from headroom.cli.install import _capture_passthrough_env + + captured = _capture_passthrough_env( + { + "ANTHROPIC_TARGET_API_URL": "https://gw.example/v1", + "OPENAI_TARGET_API_URL": "", # unset-equivalent, must be skipped + "SOME_UNRELATED_VAR": "x", + } + ) + + assert captured == {"ANTHROPIC_TARGET_API_URL": "https://gw.example/v1"} + + +def _apply_capturing_build_manifest(monkeypatch) -> dict[str, object]: + """Stub install-apply side effects and return the captured build_manifest kwargs.""" + captured: dict[str, object] = {} + + class Manifest: + profile = "default" + preset = "persistent-service" + runtime_kind = "python" + supervisor_kind = "service" + scope = "user" + health_url = "http://127.0.0.1:8787/readyz" + targets = ["claude"] + mutations: list[object] = [] + artifacts: list[object] = [] + + def fake_build_manifest(**kwargs): + captured.update(kwargs) + return Manifest() + + monkeypatch.setattr("headroom.cli.install.build_manifest", fake_build_manifest) + monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None) + monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: []) + monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: []) + monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None) + monkeypatch.setattr("headroom.cli.install.start_supervisor", lambda deployment: None) + monkeypatch.setattr("headroom.cli.install.start_detached_agent", lambda profile: None) + monkeypatch.setattr( + "headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True + ) + return captured + + +def test_install_apply_captures_target_api_url_from_env(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_TARGET_API_URL", "https://gateway.internal/v1") + captured = _apply_capturing_build_manifest(monkeypatch) + + result = CliRunner().invoke(main, ["install", "apply"]) + + assert result.exit_code == 0, result.output + # The exported gateway URL rode into the manifest env so the supervised + # proxy forwards there instead of the public Anthropic endpoint (#2240). + assert captured["extra_env"]["ANTHROPIC_TARGET_API_URL"] == "https://gateway.internal/v1" + + +def test_install_apply_explicit_env_overrides_captured(monkeypatch) -> None: + monkeypatch.setenv("ANTHROPIC_TARGET_API_URL", "https://auto.internal/v1") + captured = _apply_capturing_build_manifest(monkeypatch) + + result = CliRunner().invoke( + main, + ["install", "apply", "--env", "ANTHROPIC_TARGET_API_URL=https://explicit.internal/v1"], + ) + + assert result.exit_code == 0, result.output + # An explicit --env must win over the auto-captured value. + assert captured["extra_env"]["ANTHROPIC_TARGET_API_URL"] == "https://explicit.internal/v1" + + def test_install_status_includes_backend_from_health_probe(monkeypatch) -> None: runner = CliRunner()