From 170b04a74d5361cdfac4a6e265f5ea0dfecbd841 Mon Sep 17 00:00:00 2001 From: Abhay Singh Date: Mon, 20 Jul 2026 10:48:15 +0530 Subject: [PATCH] fix(install): carry upstream-routing env overrides into supervised deployments (#2429) ## Description Fixes #2240. `headroom install apply` builds the persistent deployment's environment from the `HEADROOM_*` family plus any explicit `--env KEY=VALUE`. It never captured the provider upstream-routing overrides that the interactive `headroom proxy` reads from the environment through `resolve_api_overrides` (`ANTHROPIC_TARGET_API_URL` and its `*_TARGET_API_URL` siblings). A supervised runner (launchd, systemd, cron, Windows service/task) starts from a bare environment, so those exports never reach the persistent proxy. The result: a user who exports `ANTHROPIC_TARGET_API_URL` pointing at their gateway and runs `install apply` gets a proxy that silently forwards to the default Anthropic endpoint instead. That is both a correctness bug and a routing surprise (traffic and keys can go to the wrong host). ## Fix Capture the documented `*_TARGET_API_URL` overrides from the current environment and merge them into the manifest env underneath the explicit `--env` map, so an explicit `--env` still wins. Scope notes: - Only URL overrides are auto-captured. The `*_TARGET_API_HEADERS` variables can carry bearer tokens, so those are deliberately left to an explicit `--env` rather than being persisted into the on-disk manifest implicitly. - The proxy already resolves these vars correctly at runtime; this only makes `install apply` hand them to the supervised process the same way the interactive proxy would inherit them. - `headroom deploy` (the Docker path) is left unchanged here; this targets the exact reported `install apply` flow. ## 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 - `headroom/cli/install.py`: add `_PASSTHROUGH_URL_ENV_VARS` and `_capture_passthrough_env`, and merge the captured overrides under the parsed `--env` map in `install_apply` before building the manifest. - `tests/test_cli/test_install_cli.py`: unit test for the capture helper (skips empty/unrelated vars), plus CliRunner tests that a set `ANTHROPIC_TARGET_API_URL` reaches `build_manifest`'s env and that an explicit `--env` overrides the captured value. ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed ### Test Output ```text $ python -m pytest tests/test_cli/test_install_cli.py -k "capture or captures or overrides" -q 3 passed $ uvx ruff@0.15.17 check headroom/cli/install.py tests/test_cli/test_install_cli.py All checks passed! $ uvx mypy@1.20.2 --ignore-missing-imports headroom/cli/install.py Success: no issues found in 1 source file ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, project venv (`uv sync --extra proxy`), `uvx ruff@0.15.17` / `uvx mypy@1.20.2`, pytest in the venv. - Exact command / steps: ran the new CliRunner tests, which export `ANTHROPIC_TARGET_API_URL` via monkeypatch, invoke `install apply` with the supervisor side effects stubbed, and capture the kwargs handed to `build_manifest`. Also called the real `_capture_passthrough_env` and real `build_manifest` directly to confirm the value lands in `manifest.base_env`. - Observed result: with the var exported, `build_manifest` received it in `extra_env` and `manifest.base_env["ANTHROPIC_TARGET_API_URL"]` held the gateway URL; with an explicit `--env ANTHROPIC_TARGET_API_URL=...` the explicit value won; empty and unrelated vars were skipped. Ran against the actual modules. - Not tested: a live launchd/systemd run forwarding to a real gateway. ## 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 - [ ] I have made corresponding changes to the documentation - [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 - [ ] I have updated the CHANGELOG.md if applicable --- headroom/cli/install.py | 43 +++++++++++++++++- tests/test_cli/test_install_cli.py | 72 ++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) 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()