Skip to content

Commit 1051b1a

Browse files
authored
Fix Windows: agent launch corruption and POSIX apiKeyHelper (#173, #116) (#177)
* Fix Windows: agent launch corruption and POSIX apiKeyHelper (#173, #116) Two Windows-only bugs broke `ucode claude`/`ucode codex`: #173 — garbled, split-screen terminal input. Both agents launched via os.execvp, but Windows has no real exec: the CRT spawns a new process and kills the parent, so the launching shell resumes and fights the agent for the console. New launcher.exec_or_spawn() keeps execvp on POSIX and spawns+waits (propagating exit code / SIGINT) on Windows, matching the pattern the token-refreshing agents already use. #116 — apiKeyHelper failed with a POSIX shell error. build_auth_shell_command emitted `if [ -n ... ]; ... | jq` and Codex ran it via `sh -c`; neither sh nor jq exists on Windows. Replaced with a hidden `ucode auth-token` command that prints the bearer via get_databricks_token (which already owns the DATABRICKS_BEARER short-circuit and PAT path). Claude's apiKeyHelper and Codex's auth block now invoke that executable directly as argv — no shell, no jq, identical on every platform. Also hardens the --use-pat path: ensure_pat_bearer() treats an empty DATABRICKS_BEARER as absent (setdefault would let it shadow the PAT and force OAuth, which can't serve a PAT-only profile), and `auth-token --use-pat` fails closed with an actionable error instead of silently trying OAuth. Co-authored-by: Isaac * Disable flaky tracing e2e test The MLflow tracing e2e races async trace ingestion against the poll timeout and fails intermittently in CI. Skip it pending a more robust verification path. Co-authored-by: Isaac
1 parent f0bd2df commit 1051b1a

12 files changed

Lines changed: 473 additions & 138 deletions

src/ucode/agents/claude.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
build_tool_base_url,
2424
get_databricks_token,
2525
)
26+
from ucode.launcher import exec_or_spawn
2627
from ucode.state import mark_tool_managed, save_state
2728
from ucode.telemetry import agent_version, ucode_version
2829
from ucode.tracing import tracing_env
@@ -467,7 +468,7 @@ def launch(state: dict, tool_args: list[str]) -> None:
467468
workspace = state.get("workspace")
468469
if workspace:
469470
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
470-
os.execvp(binary, [binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args])
471+
exec_or_spawn([binary, "--settings", str(CLAUDE_SETTINGS_PATH), *tool_args])
471472

472473

473474
def validate_cmd(binary: str) -> list[str]:

src/ucode/agents/codex.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
write_toml_file,
1717
)
1818
from ucode.databricks import (
19-
build_auth_shell_command,
19+
build_auth_token_argv,
2020
build_tool_base_url,
2121
get_databricks_token,
2222
)
23+
from ucode.launcher import exec_or_spawn
2324
from ucode.state import mark_tool_managed, save_state
2425
from ucode.telemetry import agent_version, ucode_version
2526

@@ -102,7 +103,7 @@ def _use_legacy_layout() -> bool:
102103

103104

104105
def _provider_block(workspace: str, databricks_profile: str | None, use_pat: bool = False) -> dict:
105-
auth_command = build_auth_shell_command(workspace, databricks_profile, use_pat=use_pat)
106+
auth_argv = build_auth_token_argv(workspace, databricks_profile, use_pat=use_pat)
106107
base_url = build_tool_base_url("codex", workspace)
107108
return {
108109
"name": "Databricks AI Gateway",
@@ -111,9 +112,11 @@ def _provider_block(workspace: str, databricks_profile: str | None, use_pat: boo
111112
"http_headers": {
112113
"User-Agent": f"ucode/{ucode_version()} codex/{agent_version('codex')}",
113114
},
115+
# Run the `ucode auth-token` executable directly (not via `sh -c`) so the
116+
# helper works on Windows, where there is no POSIX shell (issue #116).
114117
"auth": {
115-
"command": "sh",
116-
"args": ["-c", auth_command],
118+
"command": auth_argv[0],
119+
"args": auth_argv[1:],
117120
"timeout_ms": 5000,
118121
"refresh_interval_ms": 900000,
119122
},
@@ -356,7 +359,7 @@ def launch(state: dict, tool_args: list[str]) -> None:
356359
workspace = state.get("workspace")
357360
if workspace:
358361
os.environ["OAUTH_TOKEN"] = get_databricks_token(workspace, state.get("profile"))
359-
os.execvp(binary, [binary, "--profile", CODEX_PROFILE_NAME, *tool_args])
362+
exec_or_spawn([binary, "--profile", CODEX_PROFILE_NAME, *tool_args])
360363

361364

362365
def validate_cmd(binary: str) -> list[str]:

src/ucode/cli.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
from __future__ import annotations
55

6-
import os
76
from typing import Annotated
87

98
import typer
@@ -38,6 +37,7 @@
3837
discover_model_services,
3938
ensure_ai_gateway_v2,
4039
ensure_databricks_auth,
40+
ensure_pat_bearer,
4141
find_profile_name_for_host,
4242
get_databricks_profiles,
4343
get_databricks_token,
@@ -228,9 +228,11 @@ def configure_shared_state(
228228
f"entry under [{profile}], or re-run without --use-pat to use OAuth."
229229
)
230230
# Export the PAT for this process and launched agent subprocesses so
231-
# every token fetch takes the static-bearer path; a bearer already in
232-
# the environment wins.
233-
os.environ.setdefault("DATABRICKS_BEARER", pat)
231+
# every token fetch takes the static-bearer path. ensure_pat_bearer
232+
# keeps a non-empty pre-set bearer (CI escape hatch) but treats an
233+
# empty one as absent, so it never shadows the PAT. Pass the validated
234+
# token to avoid re-reading ~/.databrickscfg.
235+
ensure_pat_bearer(profile, pat)
234236
ensure_databricks_auth(workspace, profile)
235237
elif force_login:
236238
run_databricks_login(workspace, profile)
@@ -597,6 +599,56 @@ def mcp_web_search_cmd() -> None:
597599
serve()
598600

599601

602+
@app.command("auth-token", hidden=True)
603+
def auth_token_cmd(
604+
host: Annotated[
605+
str | None, typer.Option("--host", help="Workspace URL. Defaults to the saved workspace.")
606+
] = None,
607+
profile: Annotated[
608+
str | None, typer.Option("--profile", help="Databricks CLI profile.")
609+
] = None,
610+
use_pat: Annotated[
611+
bool, typer.Option("--use-pat", help="Read the profile's static PAT instead of OAuth.")
612+
] = False,
613+
) -> None:
614+
"""Print a Databricks bearer token to stdout, then exit.
615+
616+
This is the cross-platform helper invoked by Claude Code's `apiKeyHelper`
617+
and Codex's auth command on every token refresh. It is not meant for
618+
interactive use. All token logic (DATABRICKS_BEARER short-circuit, PAT
619+
profiles, OAuth refresh) lives in `get_databricks_token`, so the same
620+
binary works on macOS, Linux, and Windows without any POSIX shell."""
621+
import sys
622+
623+
state = load_state()
624+
workspace = host or state.get("workspace")
625+
if not workspace:
626+
print_err("No workspace configured. Run `ucode configure` first.")
627+
raise typer.Exit(1)
628+
profile = profile or state.get("profile")
629+
if use_pat or state.get("use_pat"):
630+
# --use-pat explicitly means "serve the profile's static PAT". Fail
631+
# closed if it can't be read rather than falling through to OAuth —
632+
# `auth token` cannot serve a PAT-only profile, so that path would
633+
# surface a misleading stale-login error instead of the real cause.
634+
if not ensure_pat_bearer(profile):
635+
print_err(
636+
f"--use-pat: no personal access token available for profile "
637+
f"'{profile or '<none>'}'. Add a `token = <PAT>` entry under "
638+
f"[{profile or 'your-profile'}] in ~/.databrickscfg, or re-run "
639+
"`ucode configure` without --use-pat to use OAuth."
640+
)
641+
raise typer.Exit(1)
642+
try:
643+
token = get_databricks_token(workspace, profile)
644+
except RuntimeError as exc:
645+
print_err(str(exc))
646+
raise typer.Exit(1) from None
647+
# Write the bare token (with trailing newline) to stdout — nothing else may
648+
# land on stdout or the consuming agent will treat it as part of the token.
649+
sys.stdout.write(token + "\n")
650+
651+
600652
def _auto_configure_tool(tool: str) -> None:
601653
"""First-time setup for a single tool — mirrors configure_workspace_command."""
602654
existing = load_state()

src/ucode/databricks.py

Lines changed: 60 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -704,19 +704,39 @@ def resolve_pat_token(profile: str | None) -> str | None:
704704
return None
705705

706706

707+
def ensure_pat_bearer(profile: str | None, pat: str | None = None) -> bool:
708+
"""Ensure ``DATABRICKS_BEARER`` holds a usable token for a ``--use-pat`` profile.
709+
710+
If a non-empty bearer is already in the environment it wins (the CI escape
711+
hatch). Otherwise the profile's static PAT is exported — callers that have
712+
already resolved it (e.g. ``configure_shared_state``) pass it via ``pat`` to
713+
skip a redundant ``~/.databrickscfg`` read; everyone else lets this resolve
714+
it. An exported-but-*empty* ``DATABRICKS_BEARER`` is treated as absent —
715+
matching ``get_databricks_token``'s own ``.strip()`` check — so a stray
716+
``export DATABRICKS_BEARER=`` does not shadow the PAT and silently force the
717+
OAuth path (which fails for PAT-only profiles).
718+
719+
Returns ``True`` iff a usable bearer is now present in the environment."""
720+
if os.environ.get("DATABRICKS_BEARER", "").strip():
721+
return True
722+
pat = pat or resolve_pat_token(profile)
723+
if pat:
724+
os.environ["DATABRICKS_BEARER"] = pat
725+
return True
726+
return False
727+
728+
707729
def apply_pat_environment(state: dict) -> None:
708730
"""Export the configured profile's PAT as ``DATABRICKS_BEARER`` when the
709731
workspace was configured with ``--use-pat``.
710732
711733
Every token fetch in this process (and in launched agent subprocesses,
712734
which inherit the environment) then takes the existing static-bearer
713735
short-circuit instead of the OAuth-only `databricks auth token` path.
714-
A bearer already present in the environment is left untouched."""
736+
A non-empty bearer already present in the environment is left untouched."""
715737
if not state.get("use_pat"):
716738
return
717-
pat = resolve_pat_token(state.get("profile"))
718-
if pat:
719-
os.environ.setdefault("DATABRICKS_BEARER", pat)
739+
ensure_pat_bearer(state.get("profile"))
720740

721741

722742
def run_databricks_login(workspace: str, profile: str | None = None) -> None:
@@ -1026,36 +1046,45 @@ def list_databricks_apps(workspace: str, profile: str | None = None) -> list[dic
10261046
raise RuntimeError("Databricks apps listing returned invalid JSON.") from exc
10271047

10281048

1049+
def _ucode_binary() -> str:
1050+
"""Resolve the absolute path to the running `ucode` executable.
1051+
1052+
Agents persist the auth command into config files and re-run it on every
1053+
token refresh, possibly from launchers without a full PATH (desktop GUIs).
1054+
An absolute path keeps the helper working regardless of PATH. Falls back to
1055+
the bare name when resolution fails."""
1056+
return shutil.which("ucode") or "ucode"
1057+
1058+
1059+
def build_auth_token_argv(
1060+
workspace: str, profile: str | None = None, *, use_pat: bool = False
1061+
) -> list[str]:
1062+
"""Argv for the cross-platform token helper: `ucode auth-token ...`.
1063+
1064+
Unlike the previous POSIX `databricks ... | jq` pipeline, this is a single
1065+
executable with plain arguments — no `sh`, no `jq`, no shell quoting — so it
1066+
runs identically on macOS, Linux, and Windows (issue #116). The DATABRICKS_BEARER
1067+
short-circuit and the PAT path both live inside `auth-token` itself."""
1068+
argv = [_ucode_binary(), "auth-token", "--host", workspace.rstrip("/")]
1069+
if profile:
1070+
argv += ["--profile", profile]
1071+
if use_pat:
1072+
argv.append("--use-pat")
1073+
return argv
1074+
1075+
10291076
def build_auth_shell_command(
10301077
workspace: str, profile: str | None = None, *, use_pat: bool = False
10311078
) -> str:
1032-
workspace_arg = shlex.quote(workspace.rstrip("/"))
1033-
if use_pat and profile:
1034-
# --use-pat profiles have no OAuth cache for `auth token` to read, so
1035-
# the persisted command reads the profile's static token instead.
1036-
profile_arg = shlex.quote(profile)
1037-
cli_command = (
1038-
f"databricks auth describe --profile {profile_arg} --sensitive --output json "
1039-
"| jq -r '.details.configuration.token.value'"
1040-
)
1041-
elif profile:
1042-
profile_arg = shlex.quote(profile)
1043-
cli_command = (
1044-
f"databricks auth token --host {workspace_arg} "
1045-
f"--profile {profile_arg} --force-refresh --output json "
1046-
"| jq -r '.access_token'"
1047-
)
1048-
else:
1049-
cli_command = (
1050-
"env -u DATABRICKS_CONFIG_PROFILE "
1051-
f"databricks auth token --host {workspace_arg} --force-refresh --output json "
1052-
"| jq -r '.access_token'"
1053-
)
1054-
return (
1055-
'if [ -n "${DATABRICKS_BEARER:-}" ]; then '
1056-
'printf "%s\\n" "$DATABRICKS_BEARER"; '
1057-
f"else {cli_command}; fi"
1058-
)
1079+
"""Single-line, shell-quoted form of :func:`build_auth_token_argv`.
1080+
1081+
Used where a tool wants the helper as one command *string* (Claude Code's
1082+
`apiKeyHelper`). On every platform this resolves to the `ucode auth-token`
1083+
executable rather than a POSIX shell pipeline, so no `sh`/`jq` is required."""
1084+
argv = build_auth_token_argv(workspace, profile, use_pat=use_pat)
1085+
if platform.system() == "Windows":
1086+
return subprocess.list2cmdline(argv)
1087+
return shlex.join(argv)
10591088

10601089

10611090
# A model-service's `name` is `model-services/system.ai.<model-name>`; the

src/ucode/launcher.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""Cross-platform process replacement for launching coding agents."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
import signal
7+
import subprocess
8+
import sys
9+
10+
11+
def exec_or_spawn(argv: list[str]) -> None:
12+
"""Hand the terminal to ``argv``, then exit with its status.
13+
14+
On POSIX we ``os.execvp`` — the agent process *replaces* ucode, inheriting
15+
the controlling terminal cleanly.
16+
17+
On Windows there is no real ``exec``: ``os.execvp`` spawns a *new* process
18+
and immediately terminates the parent, so the launching shell resumes its
19+
prompt and fights the agent for the console. That produces the garbled,
20+
split-screen input reported in issue #173. Instead we spawn a child, wait
21+
for it, and propagate its exit code — the same pattern the token-refreshing
22+
agents (gemini/opencode/copilot/pi) already use.
23+
"""
24+
if os.name != "nt":
25+
os.execvp(argv[0], argv)
26+
return # unreachable on POSIX; keeps type-checkers happy
27+
28+
proc = subprocess.Popen(argv)
29+
try:
30+
returncode = proc.wait()
31+
except KeyboardInterrupt:
32+
# Ctrl-C is delivered to the whole console group; let the child handle
33+
# it and report its own exit code rather than racing it.
34+
proc.send_signal(signal.SIGINT)
35+
returncode = proc.wait()
36+
sys.exit(returncode)

src/ucode/state.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
import json
66

77
from ucode.config_io import APP_DIR, is_dry_run
8-
from ucode.databricks import build_auth_shell_command, build_shared_base_urls
8+
from ucode.databricks import (
9+
build_auth_shell_command,
10+
build_auth_token_argv,
11+
build_shared_base_urls,
12+
)
913

1014
STATE_PATH = APP_DIR / "state.json"
1115
STATE_VERSION = 3
@@ -124,7 +128,9 @@ def build_agent_state(state: dict) -> dict[str, dict]:
124128
profile = state.get("profile") if isinstance(state.get("profile"), str) else None
125129
base_urls_value = state.get("base_urls")
126130
base_urls = base_urls_value if isinstance(base_urls_value, dict) else {}
127-
auth_command = build_auth_shell_command(workspace, profile, use_pat=bool(state.get("use_pat")))
131+
use_pat = bool(state.get("use_pat"))
132+
auth_command = build_auth_shell_command(workspace, profile, use_pat=use_pat)
133+
auth_argv = build_auth_token_argv(workspace, profile, use_pat=use_pat)
128134
claude_models_value = state.get("claude_models")
129135
claude_models: dict = claude_models_value if isinstance(claude_models_value, dict) else {}
130136
codex_models_value = state.get("codex_models")
@@ -155,8 +161,8 @@ def build_agent_state(state: dict) -> dict[str, dict]:
155161
"base_url": base_urls.get("codex"),
156162
"auth_command": auth_command,
157163
"auth": {
158-
"command": "sh",
159-
"args": ["-c", auth_command],
164+
"command": auth_argv[0],
165+
"args": auth_argv[1:],
160166
"timeout_ms": AUTH_COMMAND_TIMEOUT_MS,
161167
"refresh_interval_ms": AUTH_REFRESH_INTERVAL_MS,
162168
},

tests/test_agent_codex.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,14 @@ def test_provider_wire_api(self):
4545
provider = overlay["model_providers"]["ucode-databricks"]
4646
assert provider["wire_api"] == "responses"
4747

48-
def test_auth_uses_sh(self):
48+
def test_auth_runs_ucode_auth_token(self):
49+
# The auth command runs the `ucode auth-token` executable directly
50+
# (not `sh -c`), so it works on Windows where there is no POSIX shell.
4951
overlay = codex.render_overlay(WS)
5052
auth = overlay["model_providers"]["ucode-databricks"]["auth"]
51-
assert auth["command"] == "sh"
52-
assert "-c" in auth["args"]
53+
assert auth["command"].endswith("ucode") or auth["command"] == "ucode"
54+
assert auth["args"][0] == "auth-token"
55+
assert auth["command"] != "sh"
5356

5457
def test_auth_contains_workspace(self):
5558
overlay = codex.render_overlay(WS)

0 commit comments

Comments
 (0)