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
This commit is contained in:
Abhay Singh 2026-07-20 10:48:15 +05:30 committed by GitHub
parent 313c290df9
commit 170b04a74d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 114 additions and 1 deletions

View file

@ -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)

View file

@ -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()