mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
313c290df9
commit
170b04a74d
2 changed files with 114 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue