Skip to content

Commit 54bdc27

Browse files
authored
Add relayed auth MPS support for Claude Max/Enterprise (#246)
* Add relayed auth MPS support for Claude Max/Enterprise * fix e2e tests to mock interactive prompts
1 parent ead7e39 commit 54bdc27

11 files changed

Lines changed: 732 additions & 60 deletions

src/ucode/agents/__init__.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -282,25 +282,27 @@ def resolve_launch_model(
282282

283283
def resolve_provider_models(
284284
tool: str, state: dict, provider: str | None
285-
) -> tuple[dict | None, str | None]:
285+
) -> tuple[dict | None, str | None, bool]:
286286
"""Validate ``provider`` for ``tool`` and return the model ids to pin.
287287
288-
Returns ``(provider_models, error)``. ``provider_models`` is a
288+
Returns ``(provider_models, error, relayed)``. ``provider_models`` is a
289289
``{family: model_id}`` dict for a Bedrock-backed claude service (whose
290290
provider-side ids must be pinned explicitly), or None for an Anthropic/
291-
canonical service or when ``provider`` is None. A non-None ``error`` means
292-
the provider is invalid for the tool (wrong type, missing, feature off, or a
293-
Bedrock service with no Claude models) and the caller should not launch.
291+
canonical service or when ``provider`` is None. ``relayed`` is True for a
292+
credential-less Anthropic subscription relay, which the launch path wires
293+
with the relayed overlay + refresh proxy. A non-None ``error`` means the
294+
provider is invalid for the tool and the caller should not launch.
294295
"""
295296
if not provider:
296-
return None, None
297+
return None, None, False
297298
token = get_databricks_token(state["workspace"], state.get("profile"))
298299
service, error = resolve_provider_service(tool, provider, state["workspace"], token)
299300
if error or service is None:
300-
return None, error
301+
return None, error, False
302+
relayed = bool(service.get("relayed"))
301303
if service["provider_type"] in BEDROCK_PROVIDER_TYPES:
302-
return map_bedrock_claude_models(service.get("targets") or []), None
303-
return None, None
304+
return map_bedrock_claude_models(service.get("targets") or []), None, relayed
305+
return None, None, relayed
304306

305307

306308
def configure_tool(
@@ -309,6 +311,7 @@ def configure_tool(
309311
model: str | None = None,
310312
provider: str | None = None,
311313
provider_models: dict[str, str] | None = None,
314+
relayed: bool = False,
312315
) -> dict:
313316
result: dict | tuple[dict, str]
314317
if tool == "codex":
@@ -319,7 +322,7 @@ def configure_tool(
319322
if not model and not provider:
320323
raise RuntimeError(f"A {tool} model must be selected before configuration.")
321324
result = claude.write_tool_config(
322-
state, model, provider=provider, provider_models=provider_models
325+
state, model, provider=provider, provider_models=provider_models, relayed=relayed
323326
)
324327
else:
325328
# provider routing is claude/codex-only; every other tool needs a model.
@@ -409,10 +412,12 @@ def configure_single_tool(tool: str, state: dict) -> dict:
409412
def _configure_one(tool: str, state: dict, provider: str | None) -> dict:
410413
"""Write one tool's config, routing through ``provider`` when set."""
411414
if provider:
412-
provider_models, error = resolve_provider_models(tool, state, provider)
415+
provider_models, error, relayed = resolve_provider_models(tool, state, provider)
413416
if error:
414417
raise RuntimeError(error)
415-
return configure_tool(tool, state, None, provider=provider, provider_models=provider_models)
418+
return configure_tool(
419+
tool, state, None, provider=provider, provider_models=provider_models, relayed=relayed
420+
)
416421
if tool == "codex":
417422
return configure_tool("codex", state)
418423
state, model = resolve_launch_model(tool, state, None)
@@ -481,6 +486,10 @@ def validate_tool(tool: str) -> tuple[bool, str]:
481486
spec = TOOL_SPECS[tool]
482487
binary = spec["binary"]
483488
module = _MODULES[tool]
489+
# Some configs (e.g. claude relayed) can't be probed with a live message —
490+
# the proxy + subscription login only exist at launch. Trust the written config.
491+
if hasattr(module, "skip_validation") and module.skip_validation(load_state()):
492+
return True, ""
484493
cmd = module.validate_cmd(binary)
485494
env = None
486495
if hasattr(module, "validate_env"):

src/ucode/agents/claude.py

Lines changed: 166 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
import os
88
import re
99
import shutil
10+
import signal
11+
import socket
1012
import subprocess
13+
import threading
1114
from pathlib import Path
1215
from typing import cast
1316

@@ -110,6 +113,22 @@ def _resolve_web_search_model(state: dict) -> str | None:
110113
# must be replaced, not just left alone.
111114
MAXIMUM_MLFLOW_VERSION = (3, 12)
112115

116+
# Relayed drops the user scope to deliberately omit the stale apiKeyHelper. Only applied to relayed
117+
# launches — normal launches keep loading user settings (hooks/permissions) as before.
118+
_RELAYED_SETTING_SOURCES = "project,local"
119+
120+
121+
def relayed_proxy_base_url(state: dict) -> str:
122+
"""Loopback base URL for the relayed refresh proxy, allocating a free port
123+
on first call and caching it in state so config and launch agree."""
124+
port = state.get("relayed_proxy_port")
125+
if not isinstance(port, int):
126+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
127+
sock.bind(("127.0.0.1", 0))
128+
port = sock.getsockname()[1]
129+
state["relayed_proxy_port"] = port
130+
return f"http://127.0.0.1:{port}"
131+
113132

114133
def _web_search_mcp_entry(workspace: str, search_model: str, profile: str | None = None) -> dict:
115134
"""Stdio MCP server entry pointing at `ucode mcp web-search`. Resolves
@@ -140,6 +159,8 @@ def render_overlay(
140159
provider: str | None = None,
141160
provider_models: dict[str, str] | None = None,
142161
fable_enabled: bool = False,
162+
relayed: bool = False,
163+
relayed_base_url: str | None = None,
143164
) -> tuple[dict, list[list[str]]]:
144165
"""Return (overlay, managed_key_paths) for Claude settings.json.
145166
@@ -153,8 +174,20 @@ def render_overlay(
153174
understands Claude Code's own canonical model names, so no model id is
154175
pinned. A Bedrock-backed provider exposes different model ids (e.g.
155176
`us.anthropic.claude-sonnet-4-6`), passed in `provider_models` by family —
156-
those get pinned via the `ANTHROPIC_DEFAULT_*_MODEL` env vars."""
157-
base_url = build_tool_base_url("claude", workspace)
177+
those get pinned via the `ANTHROPIC_DEFAULT_*_MODEL` env vars.
178+
179+
When `relayed` is set (a credential-less Anthropic subscription-relay MPS,
180+
Claude Max/Team/Enterprise), Claude Code's own keychain OAuth must remain the
181+
`Authorization` credential, so no `apiKeyHelper` is written (it would outrank
182+
the subscription OAuth). The Databricks credential rides in the
183+
`X-Databricks-AI-Gateway-Token` swap header, injected per request by a local
184+
refresh proxy at `relayed_base_url` — not written here."""
185+
if relayed:
186+
if not relayed_base_url:
187+
raise RuntimeError("Relayed launch requires a proxy base URL.")
188+
base_url = relayed_base_url
189+
else:
190+
base_url = build_tool_base_url("claude", workspace)
158191
# ANTHROPIC_CUSTOM_HEADERS is parsed as `key: value` pairs separated by
159192
# newlines (Anthropic SDK convention). Setting User-Agent here overrides
160193
# the SDK's default UA on outbound requests so the gateway can attribute
@@ -165,6 +198,8 @@ def render_overlay(
165198
]
166199
if provider:
167200
header_lines.append(f"Databricks-Model-Provider-Service: {provider}")
201+
# Relayed: the X-Databricks-AI-Gateway-Token swap header is added per request
202+
# by the refresh proxy, not here — a static value would go stale mid-session.
168203
custom_headers = "\n".join(header_lines)
169204
env: dict[str, str] = {
170205
"ANTHROPIC_BASE_URL": base_url,
@@ -217,11 +252,14 @@ def render_overlay(
217252
env["ANTHROPIC_DEFAULT_SONNET_MODEL"] = _maybe_add_1m_suffix(claude_models["sonnet"])
218253
if claude_models.get("haiku"):
219254
env["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = claude_models["haiku"]
220-
overlay: dict = {
221-
"apiKeyHelper": build_auth_shell_command(workspace, profile, use_pat=use_pat),
222-
"env": env,
223-
}
224-
keys: list[list[str]] = [["apiKeyHelper"]] + [["env", k] for k in env]
255+
# Relayed omits apiKeyHelper so Claude Code's subscription OAuth stays the
256+
# Authorization credential; every other path uses it as the gateway auth.
257+
overlay: dict = {"env": env}
258+
if relayed:
259+
keys = [["env", k] for k in env]
260+
else:
261+
overlay["apiKeyHelper"] = build_auth_shell_command(workspace, profile, use_pat=use_pat)
262+
keys = [["apiKeyHelper"]] + [["env", k] for k in env]
225263

226264
# Disable Claude Code's built-in WebSearch: it declares Anthropic's hosted
227265
# `web_search_20250305` server tool, which the Databricks gateway rejects
@@ -303,9 +341,13 @@ def write_tool_config(
303341
model: str | None,
304342
provider: str | None = None,
305343
provider_models: dict[str, str] | None = None,
344+
relayed: bool = False,
306345
) -> dict:
307346
backup_existing_file(CLAUDE_SETTINGS_PATH, CLAUDE_BACKUP_PATH)
308347
web_search_model = _resolve_web_search_model(state)
348+
# Relayed inference points at a local refresh proxy; its loopback base URL is
349+
# recorded in state so launch starts the proxy on the matching port.
350+
relayed_base_url = relayed_proxy_base_url(state) if relayed else None
309351
overlay, managed_keys = render_overlay(
310352
state["workspace"],
311353
model,
@@ -316,6 +358,8 @@ def write_tool_config(
316358
provider=provider,
317359
provider_models=provider_models,
318360
fable_enabled=bool(state.get("fable_enabled")),
361+
relayed=relayed,
362+
relayed_base_url=relayed_base_url,
319363
)
320364
tracing_env_vars = tracing_env(state, "claude")
321365
stop_hook_command = claude_tracing_stop_hook_command() if tracing_env_vars else None
@@ -334,6 +378,10 @@ def write_tool_config(
334378

335379
existing = read_json_safe(CLAUDE_SETTINGS_PATH)
336380
merged = deep_merge_dict(existing, overlay)
381+
# Drop any apiKeyHelper a prior non-relayed launch left in the file; relayed
382+
# must not carry one (it would outrank the subscription OAuth).
383+
if relayed:
384+
merged.pop("apiKeyHelper", None)
337385
if tracing_env_vars and stop_hook_command:
338386
_upsert_tracing_stop_hook(merged, stop_hook_command)
339387
if not tracing_env_vars:
@@ -361,6 +409,13 @@ def write_tool_config(
361409
if web_search_model:
362410
_register_web_search_mcp(state["workspace"], web_search_model, state.get("profile"))
363411

412+
# Persist relayed mode + proxy port so launch() wires the refresh proxy and
413+
# subscription login; cleared on a non-relayed launch.
414+
if relayed:
415+
state["claude_relayed"] = True
416+
else:
417+
state.pop("claude_relayed", None)
418+
state.pop("relayed_proxy_port", None)
364419
state = mark_tool_managed(state, "claude", managed_keys)
365420
save_state(state)
366421
return state
@@ -628,7 +683,7 @@ def _merge_claude_settings(base: dict, overlay: dict) -> dict:
628683
return merged
629684

630685

631-
def _build_claude_argv(binary: str, tool_args: list[str]) -> list[str]:
686+
def _build_claude_argv(binary: str, tool_args: list[str], relayed: bool = False) -> list[str]:
632687
"""Build the ``claude`` argv, composing any caller ``--settings`` with
633688
ucode's managed settings.
634689
@@ -644,24 +699,118 @@ def _build_claude_argv(binary: str, tool_args: list[str]) -> list[str]:
644699
accumulate one another's hooks. A caller ``--settings`` value ucode cannot
645700
resolve raises (see :func:`_load_caller_settings`) rather than being passed
646701
through as a second, colliding flag.
702+
703+
``relayed`` adds ``--setting-sources`` to exclude the user scope (see
704+
:data:`_RELAYED_SETTING_SOURCES`), so a stale user-scope apiKeyHelper cannot
705+
filter through and shadow the subscription OAuth.
647706
"""
707+
source_args = ["--setting-sources", _RELAYED_SETTING_SOURCES] if relayed else []
648708
caller_values, remaining = _extract_caller_settings(tool_args)
649709
if not caller_values:
650710
# No caller --settings: hand Claude ucode's settings file directly (the
651711
# common path; behavior unchanged).
652-
return [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]
712+
return [binary, *source_args, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args]
653713
caller_settings: dict = {}
654714
for value in caller_values:
655715
caller_settings = _merge_claude_settings(caller_settings, _load_caller_settings(value))
656716
# ucode wins over the caller for conflicting keys (protects gateway auth);
657717
# hooks from both sides survive.
658718
merged = _merge_claude_settings(caller_settings, read_json_safe(CLAUDE_SETTINGS_PATH))
659-
return [binary, "--settings", json.dumps(merged, separators=(",", ":")), *remaining]
719+
return [
720+
binary,
721+
*source_args,
722+
"--settings",
723+
json.dumps(merged, separators=(",", ":")),
724+
*remaining,
725+
]
726+
727+
728+
def _has_subscription_login() -> bool:
729+
"""True when Claude Code already holds a subscription login (`claude auth
730+
status` exits 0). Never inspects or captures the credential itself."""
731+
try:
732+
result = subprocess.run(
733+
[SPEC["binary"], "auth", "status"],
734+
check=False,
735+
capture_output=True,
736+
text=True,
737+
timeout=30,
738+
)
739+
except (OSError, subprocess.TimeoutExpired):
740+
return False
741+
return result.returncode == 0
742+
743+
744+
def _ensure_subscription_login() -> None:
745+
"""Ensure Claude Code has a persisted subscription login, running the browser
746+
flow via `claude auth login` if not. ucode never sees or stores the token —
747+
Claude Code persists it to its own secure store and refreshes it natively."""
748+
if _has_subscription_login():
749+
return
750+
print_note("Opening browser to sign in with your Claude subscription...")
751+
try:
752+
subprocess.run([SPEC["binary"], "auth", "login"], check=True, timeout=300)
753+
except subprocess.CalledProcessError as exc:
754+
raise RuntimeError("`claude auth login` failed.") from exc
755+
except subprocess.TimeoutExpired as exc:
756+
raise RuntimeError("`claude auth login` timed out.") from exc
757+
print_success("Claude subscription authenticated")
758+
759+
760+
def _rewrite_relayed_port(state: dict, port: int) -> None:
761+
"""Point the persisted config + state at ``port`` after the proxy had to bind
762+
a different port than the cached one. Keeps ANTHROPIC_BASE_URL (which Claude
763+
Code reads) in sync with the live proxy so requests reach it."""
764+
state["relayed_proxy_port"] = port
765+
save_state(state)
766+
settings = read_json_safe(CLAUDE_SETTINGS_PATH)
767+
env = settings.get("env")
768+
if isinstance(env, dict):
769+
env["ANTHROPIC_BASE_URL"] = f"http://127.0.0.1:{port}"
770+
write_json_file(CLAUDE_SETTINGS_PATH, settings)
771+
772+
773+
def _launch_relayed(state: dict, binary: str, tool_args: list[str]) -> None:
774+
"""Relayed launch: sign into the Claude subscription, start the loopback
775+
refresh proxy, then run Claude Code alongside it (the proxy must outlive the
776+
exec, so we spawn-and-wait rather than replacing the process)."""
777+
from ucode.gateway_proxy import start_proxy
778+
779+
_ensure_subscription_login()
780+
workspace = state["workspace"]
781+
port = state.get("relayed_proxy_port")
782+
if not isinstance(port, int):
783+
raise RuntimeError("Relayed proxy port was not configured; re-run `ucode claude`.")
784+
785+
server, cache = start_proxy(workspace, state.get("profile"), port)
786+
# start_proxy falls back to an OS-assigned port when the cached one is taken
787+
# (stale proxy from a killed session). Reconcile settings + state to whatever
788+
# it actually bound, so Claude Code connects to the live port.
789+
bound_port = server.server_address[1]
790+
if bound_port != port:
791+
_rewrite_relayed_port(state, bound_port)
792+
793+
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
794+
server_thread.start()
795+
796+
proc = subprocess.Popen(_build_claude_argv(binary, tool_args, relayed=True))
797+
try:
798+
returncode = proc.wait()
799+
except KeyboardInterrupt:
800+
proc.send_signal(signal.SIGINT)
801+
returncode = proc.wait()
802+
finally:
803+
cache.stop()
804+
server.shutdown()
805+
raise SystemExit(returncode)
660806

661807

662808
def launch(state: dict, tool_args: list[str]) -> None:
663809
binary = SPEC["binary"]
664810
workspace = state.get("workspace")
811+
if state.get("claude_relayed"):
812+
_launch_relayed(state, binary, tool_args)
813+
return
665814
if workspace:
666815
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
667816
exec_or_spawn(_build_claude_argv(binary, tool_args))
@@ -677,3 +826,10 @@ def validate_cmd(binary: str) -> list[str]:
677826
"--max-turns",
678827
"1",
679828
]
829+
830+
831+
def skip_validation(state: dict) -> bool:
832+
"""Relayed configs can't be probed with a live message: the loopback proxy
833+
and subscription login are only established at launch, so a validation-time
834+
request has nothing listening and would hang (and burn subscription quota)."""
835+
return bool(state.get("claude_relayed"))

0 commit comments

Comments
 (0)