77import os
88import re
99import shutil
10+ import signal
11+ import socket
1012import subprocess
13+ import threading
1114from pathlib import Path
1215from 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.
111114MAXIMUM_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
114133def _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
662808def 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