Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions src/ucode/agents/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
ug_binary,
)
from ucode.launcher import exec_or_spawn
from ucode.managed_config import refresh_managed_config
from ucode.managed_files import (
OS,
ManagedFileSnapshots,
Expand Down Expand Up @@ -213,6 +214,19 @@ def _otel_trace_env(workspace: str) -> dict[str, str]:
_RELAYED_SETTING_SOURCES = "project,local"


def _apply_managed_header_lines(
ucode_lines: list[str], managed_http_headers: dict[str, str] | None
) -> list[str]:
"""Overlay admin ``managed_http_headers`` onto ucode's header lines; admin wins by name."""
lines_by_name: dict[str, str] = {}
for line in ucode_lines:
name, _separator, _value = line.partition(":")
lines_by_name[name.strip().casefold()] = line
for name, value in (managed_http_headers or {}).items():
lines_by_name[name.strip().casefold()] = f"{name}: {value}"
return list(lines_by_name.values())


def configured_paths(state: dict) -> list[str]:
"""The Claude config file ug writes; the OS-managed file is added by the dispatcher."""
return [str(CLAUDE_SETTINGS_PATH)]
Expand Down Expand Up @@ -353,6 +367,7 @@ def render_overlay(
static_models: list[str] | None = None,
otel_tracing: bool = False,
picker_catalog: AnthropicModelCatalog | None = None,
managed_http_headers: dict[str, str] | None = None,
) -> tuple[dict, list[list[str]]]:
"""Return (overlay, managed_key_paths) for Claude settings.json.

Expand Down Expand Up @@ -396,7 +411,7 @@ def render_overlay(
header_lines.append(f"{SMART_ROUTER_RECIPE_HEADER}: {configured_router_name()}")
# Relayed: the X-Databricks-AI-Gateway-Token swap header is added per request
# by the refresh proxy, not here — a static value would go stale mid-session.
custom_headers = "\n".join(header_lines)
custom_headers = "\n".join(_apply_managed_header_lines(header_lines, managed_http_headers))
env: dict[str, str] = {
"ANTHROPIC_BASE_URL": base_url,
"ANTHROPIC_CUSTOM_HEADERS": custom_headers,
Expand Down Expand Up @@ -876,6 +891,11 @@ def write_tool_config(
# revert would restore that snapshot instead of deleting the file.
if not is_tool_managed(state, "claude"):
backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH)
# A managed config makes ug authoritative over the whole custom-header value, so it is
# overwritten wholesale; without one, preserve the developer's own pre-existing headers. Reuses
# this launch's warm managed-config cache (no extra round trip); a failed fetch degrades to None
# (treated as unmanaged), never blocking the write.
managed_config_present = refresh_managed_config(state).manifest is not None
previous_keys = ((state.get("managed_configs") or {}).get("claude") or {}).get("keys", [])
web_search_model = _resolve_web_search_model(state)
# Relayed inference points at a local refresh proxy; its loopback base URL is
Expand All @@ -899,6 +919,7 @@ def write_tool_config(
static_models=state.get("claude_static_models"),
otel_tracing=bool(state.get("claude_otel_tracing")),
picker_catalog=picker_catalog,
managed_http_headers=state.get("claude_http_headers"),
)
source_scoped_defaults = bool((provider or parent_schema) and coding_agent_config_defaults)
# Native discovery must not inherit UG's prior static allow-list. Keep a replacement picker
Expand Down Expand Up @@ -985,9 +1006,16 @@ def _compose(
for key in stale_picker_keys:
merged.pop(key, None)
overlay_custom_headers = overlay_for_merge["env"][ANTHROPIC_CUSTOM_HEADERS_ENV_KEY]
merged["env"][ANTHROPIC_CUSTOM_HEADERS_ENV_KEY] = _merge_anthropic_custom_headers(
existing_custom_headers, overlay_custom_headers
)
if managed_config_present:
# ug owns the whole value under a managed config: overwrite wholesale so a header ug no
# longer emits is dropped and no stale or foreign header lingers.
merged["env"][ANTHROPIC_CUSTOM_HEADERS_ENV_KEY] = overlay_custom_headers
else:
# No managed config: preserve the developer's own pre-existing headers, replacing only
# the header names ug manages.
merged["env"][ANTHROPIC_CUSTOM_HEADERS_ENV_KEY] = _merge_anthropic_custom_headers(
existing_custom_headers, overlay_custom_headers
)
# Drop any apiKeyHelper a prior non-relayed launch left in the file; relayed
# must not carry one (it would outrank the subscription OAuth).
if relayed:
Expand Down
228 changes: 218 additions & 10 deletions tests/test_agent_claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import shlex
import subprocess
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock

import pytest
Expand All @@ -29,11 +30,28 @@ def _proxy_argv() -> list[str]:
return build_mcp_proxy_argv(GH_URL, WS, "p")


def _managed_config_result(manifest: dict | None) -> SimpleNamespace:
"""A stand-in for `ManagedConfigResult` exposing only the `.manifest` attribute
`write_tool_config` reads."""
return SimpleNamespace(manifest=manifest)


@pytest.fixture(autouse=True)
def _avoid_real_managed_settings(monkeypatch):
monkeypatch.setattr(claude, "_managed_settings_path", lambda: None)


@pytest.fixture(autouse=True)
def _default_managed_config_present(monkeypatch):
"""`write_tool_config` now decides overwrite-vs-preserve itself by calling
`refresh_managed_config`. Default every test to "managed present" (current-HEAD wholesale
overwrite), matching pre-existing tests that don't care about this axis, so they need no
per-test mock; tests exercising the unmanaged path override this explicitly."""
monkeypatch.setattr(
claude, "refresh_managed_config", lambda *a, **kw: _managed_config_result({"claude": {}})
)


class TestClaudeSpec:
def test_binary(self):
assert claude.SPEC["binary"] == "claude"
Expand Down Expand Up @@ -381,6 +399,28 @@ def test_parent_adds_discovery_header(self):
assert "ANTHROPIC_DEFAULT_SONNET_MODEL" not in overlay["env"]
assert "availableModels" not in overlay

def test_managed_http_headers_added(self):
overlay, _ = claude.render_overlay(
WS, "s4", managed_http_headers={"x-databricks-workspace": "eng-ml-inference"}
)
lines = overlay["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert "x-databricks-workspace: eng-ml-inference" in lines
# ucode's own headers are still emitted alongside the admin header.
assert "x-databricks-use-coding-agent-mode: true" in lines

def test_managed_http_headers_override_ucode_header_in_place(self, monkeypatch):
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
overlay, _ = claude.render_overlay(
WS, "s4", managed_http_headers={"User-Agent": "admin-agent/9"}
)
lines = overlay["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
# Admin wins on a case-insensitive name collision, replacing ucode's line in its position.
assert lines == [
"x-databricks-use-coding-agent-mode: true",
"User-Agent: admin-agent/9",
]

def test_bedrock_provider_pins_model_ids(self):
provider_models = {
"opus": "global.anthropic.claude-opus-4-8",
Expand Down Expand Up @@ -966,29 +1006,197 @@ def test_managed_file_strips_stale_gateway_model_discovery(self, monkeypatch):
not in json.loads(managed_writes[0][1])["env"]
)

def test_managed_file_merges_anthropic_custom_headers(self, monkeypatch):
def test_writes_admin_http_headers(self, monkeypatch):
private_writes: list = []
managed_writes: list = []
self._patch(monkeypatch, private_writes, managed_writes)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
state = {
"workspace": WS,
"codex_models": [],
"claude_http_headers": {"x-databricks-workspace": "eng-ml-inference"},
}

claude.write_tool_config(state, "databricks-claude-sonnet-4")

_, payload = private_writes[0]
lines = payload["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert "x-databricks-workspace: eng-ml-inference" in lines # admin header applied
assert "x-databricks-use-coding-agent-mode: true" in lines # ucode's own header kept

def test_drops_admin_http_header_after_removal(self, monkeypatch):
# ucode owns ucode-settings.json, so a managed header it no longer emits is dropped with no
# cross-run state: every header already in that file was written by ucode.
private_writes: list = []
managed_writes: list = []
existing = {
str(claude.CLAUDE_SETTINGS_PATH): {
"env": {
"ANTHROPIC_CUSTOM_HEADERS": (
"x-databricks-use-coding-agent-mode: true\n"
"User-Agent: ucode/1.0 claude/2.0\n"
"x-databricks-workspace: eng-ml-inference"
)
}
}
}
self._patch(monkeypatch, private_writes, managed_writes, existing)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
# The admin removed the header from managed config; this configure omits it.
state = {"workspace": WS, "codex_models": []}

claude.write_tool_config(state, "databricks-claude-sonnet-4")

_, payload = private_writes[0]
lines = payload["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert "x-databricks-workspace: eng-ml-inference" not in lines # dropped on removal
assert "x-databricks-use-coding-agent-mode: true" in lines # ucode's own header kept

def test_managed_file_overwrites_dropping_foreign_and_removed_headers(self, monkeypatch):
# Wholesale overwrite: the written value is exactly ucode's static headers plus the admin's
# CURRENT http_headers manifest, nothing else. A header that only exists directly in the
# managed file's ANTHROPIC_CUSTOM_HEADERS -- not in ucode's static set and not in the
# manifest -- is dropped just like a stale ucode-written one; a manifest header is present,
# and removing it from the manifest on a later run drops it too.
private_writes: list = []
managed_writes: list = []
existing_managed_settings = {
existing = {
str(FAKE_MANAGED_PATH): {
"env": {"ANTHROPIC_CUSTOM_HEADERS": "X-Enterprise-Header: retain\nUser-Agent: old"}
"env": {
"ANTHROPIC_CUSTOM_HEADERS": (
"X-Foreign-Header: keep-me\n"
"x-databricks-use-coding-agent-mode: true\n"
"x-team: stale-team"
)
}
}
}
self._patch(monkeypatch, private_writes, managed_writes, existing_managed_settings)
self._patch(monkeypatch, private_writes, managed_writes, existing)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
state = {
"workspace": WS,
"codex_models": [],
"claude_http_headers": {"x-team": "eng-ml"},
}

claude.write_tool_config(state, "databricks-claude-sonnet-4")

lines = json.loads(managed_writes[0][1])["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert "X-Foreign-Header: keep-me" not in lines # not ucode's, not in the manifest
assert "x-team: eng-ml" in lines # current manifest header -> present

# A later run without the manifest header drops it too.
existing[str(FAKE_MANAGED_PATH)] = json.loads(managed_writes[0][1])
managed_writes.clear()
state["claude_http_headers"] = {}

claude.write_tool_config(state, "databricks-claude-sonnet-4")

lines = json.loads(managed_writes[0][1])["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert not any(line.startswith("x-team:") for line in lines)

def test_unmanaged_preserves_foreign_header_and_replaces_ucode_headers_in_place(
self, monkeypatch
):
# No admin CodingAgentConfig: Lilly's original merge preserves the developer's own headers,
# replacing only the header names ug manages, in their existing positions.
monkeypatch.setattr(
claude, "refresh_managed_config", lambda *a, **kw: _managed_config_result(None)
)
private_writes: list = []
managed_writes: list = []
existing = {
str(claude.CLAUDE_SETTINGS_PATH): {
"env": {
"ANTHROPIC_CUSTOM_HEADERS": (
"X-Foreign-Header: keep-me\n"
"x-databricks-use-coding-agent-mode: false\n"
"User-Agent: old-agent"
)
}
}
}
self._patch(monkeypatch, private_writes, managed_writes, existing)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
state = {"workspace": WS, "codex_models": []}

claude.write_tool_config(state, "databricks-claude-sonnet-4")

_, text = managed_writes[0]
merged_headers = json.loads(text)["env"]["ANTHROPIC_CUSTOM_HEADERS"]
assert merged_headers.splitlines() == [
"X-Enterprise-Header: retain", # Preserved from existing managed settings.
"User-Agent: ucode/1.0 claude/2.0", # From ucode; overwrites existing.
"x-databricks-use-coding-agent-mode: true", # Newly added by ucode.
lines = private_writes[0][1]["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()
assert lines == [
"X-Foreign-Header: keep-me", # not ug's, survives untouched
"x-databricks-use-coding-agent-mode: true", # ug-managed name, replaced in place
"User-Agent: ucode/1.0 claude/2.0", # ug-managed name, replaced in place
]

def test_managed_file_wholesale_overwrite_survives_real_reconcile_round_trip(
self, tmp_path, monkeypatch
):
# Drives the REAL managed_files snapshot/reconcile flow (not a hand-mocked snapshot) across
# three launches: a header hand-placed directly in the managed file -- never ucode's, never
# in the admin manifest -- never survives a write; a manifest header is stable across a
# no-op re-run; and removing it from the manifest drops it on the next run.
managed_path = tmp_path / "managed-settings.json"
backup_dir = tmp_path / "managed-backups"
monkeypatch.setattr(managed_files, "managed_files_supported", lambda: True)
monkeypatch.setattr(managed_files, "MANAGED_BACKUP_DIR", backup_dir)
monkeypatch.setattr(
managed_files, "MANAGED_BACKUP_MANIFEST_PATH", backup_dir / "manifest.json"
)
monkeypatch.setattr(
managed_files,
"_sudo_replace",
lambda target, text: target.write_text(text, encoding="utf-8"),
)
monkeypatch.setattr(claude, "_managed_settings_path", lambda: managed_path)
monkeypatch.setattr(claude, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(managed_files, "managed_writes_allowed", lambda: True)
monkeypatch.setattr(claude, "backup_existing_file", lambda *a, **kw: True)
monkeypatch.setattr(claude, "save_state", lambda state: None)
monkeypatch.setattr(claude, "ug_version", lambda: "1.0")
monkeypatch.setattr(claude, "agent_version", lambda _binary: "2.0")
monkeypatch.setattr(claude, "CLAUDE_SETTINGS_PATH", tmp_path / "ucode-settings.json")
# Managed variant: an admin CodingAgentConfig is present, so ug owns the value wholesale.
monkeypatch.setattr(
claude,
"refresh_managed_config",
lambda *a, **kw: _managed_config_result({"claude": {}}),
)

# A hand edit directly in the managed file, bypassing both ucode and the admin manifest.
managed_path.write_text(
json.dumps({"env": {"ANTHROPIC_CUSTOM_HEADERS": "X-Direct-Edit: should-not-survive"}}),
encoding="utf-8",
)

def custom_headers() -> list[str]:
written = json.loads(managed_path.read_text())
return written["env"]["ANTHROPIC_CUSTOM_HEADERS"].splitlines()

state = {
"workspace": WS,
"codex_models": [],
"claude_http_headers": {"x-team": "eng-ml"},
}
claude.write_tool_config(state, "databricks-claude-sonnet-4")

first = custom_headers()
assert "X-Direct-Edit: should-not-survive" not in first # hand edit -> dropped
assert "x-team: eng-ml" in first # current manifest header -> present

# A no-op re-run (same manifest) leaves the value stable.
claude.write_tool_config(state, "databricks-claude-sonnet-4")
assert custom_headers() == first

# The admin removes the header from the manifest; the next run drops it.
state["claude_http_headers"] = {}
claude.write_tool_config(state, "databricks-claude-sonnet-4")
assert not any(line.startswith("x-team:") for line in custom_headers())

def test_managed_file_applies_model_default_precedence(self, monkeypatch):
managed_defaults = self._write_managed_model_defaults(
monkeypatch,
Expand Down
30 changes: 30 additions & 0 deletions tests/test_e2e_user_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,36 @@ def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_serv
assert req is not None, _no_request_msg(capture_server, result)
_assert_ua(req, _expected_ua("claude", "claude"))

def test_managed_http_header_arrives_at_gateway(self, tmp_path, monkeypatch, capture_server):
import ucode.config_io as config_io_mod
from ucode.agents import claude

_require_binary("claude")
config_dir = tmp_path / "claude_config"
config_dir.mkdir()
monkeypatch.setattr(config_io_mod, "APP_DIR", tmp_path)
monkeypatch.setattr(claude, "CLAUDE_SETTINGS_PATH", config_dir / "settings.json")
monkeypatch.setattr(claude, "CLAUDE_BACKUP_PATH", tmp_path / "claude.backup.json")

overlay, _ = claude.render_overlay(
capture_server.base_url,
"test-model",
managed_http_headers={"x-databricks-workspace": "eng-ml-inference"},
)
claude.CLAUDE_SETTINGS_PATH.write_text(json.dumps(overlay), encoding="utf-8")
env = {
**os.environ,
"CLAUDE_CONFIG_DIR": str(config_dir),
"ANTHROPIC_API_KEY": "test-key-not-real",
**overlay["env"],
}

result = _run_until_first_request(claude.validate_cmd("claude"), env)

req = capture_server.first_request_with_path_prefix("/ai-gateway/anthropic")
assert req is not None, _no_request_msg(capture_server, result)
assert _header(req, "x-databricks-workspace") == "eng-ml-inference"


class TestCodexUserAgent:
def test_user_agent_arrives_at_gateway(self, tmp_path, monkeypatch, capture_server):
Expand Down
Loading