Skip to content

Commit 04b27f7

Browse files
rohita5lclaude
andauthored
Instrument databricks auth for UCODE_DEBUG diagnostics (#80)
* Instrument databricks auth for UCODE_DEBUG diagnostics Adds opt-in debug logging to diagnose cases where `databricks auth token` returns no token even though the CLI itself is authenticated. When `UCODE_DEBUG=1`, `has_valid_databricks_auth` and `get_databricks_token` now dump (once per process) the CLI version, the scrubbed profiles list, the scrubbed `~/.databrickscfg`, and the set of `DATABRICKS_*` env vars visible to the subprocess, plus the rc/stderr of each `auth token` call. A `_format_subprocess_result` helper suppresses stdout on success so the access token never lands in `~/.ucode/debug.log`. Also removes the dead `databricks auth login --no-browser` retry block in `get_databricks_token`: with `capture_output=True` the device-code URL is never visible to the user, so the retry can never complete a fresh auth — it only obscures the original failure. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Restore get_databricks_token retry and add debug coverage for reauth path The previous commit removed the silent re-auth retry on the grounds that --no-browser can never complete a fresh auth. That's true, but the retry also handles OAuth token refresh for sessions that just need a silent refresh — which the e2e TestGeminiAuthRecovery test exercises. Restores the _fetch / auth login --no-browser / _fetch retry loop, adds debug logging around the reauth attempt (triggered/rc/stderr), and restores the unit test that covers the retry path. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Update uv.lock registry source to pypi-proxy.cloud.databricks.com Reflects the current internal PyPI proxy (go/pypi-registry-access). Download URLs remain files.pythonhosted.org so external users are unaffected. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9822e10 commit 04b27f7

3 files changed

Lines changed: 269 additions & 45 deletions

File tree

src/ucode/databricks.py

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

44
from __future__ import annotations
55

6+
import functools
67
import json
78
import logging
89
import logging.handlers
@@ -12,6 +13,7 @@
1213
import shlex
1314
import shutil
1415
import subprocess
16+
from pathlib import Path
1517
from typing import cast
1618
from urllib import error as urllib_error
1719
from urllib import request as urllib_request
@@ -88,6 +90,105 @@ def _debug(label: str, detail: str) -> None:
8890
logger.debug("%s: %s", label, detail)
8991

9092

93+
_SECRET_KEY_PATTERN = re.compile(
94+
r"(token|secret|password|bearer|api_key|apikey)", re.IGNORECASE
95+
)
96+
97+
98+
def _format_subprocess_result(
99+
result: subprocess.CompletedProcess[str],
100+
) -> str:
101+
"""Format a CompletedProcess for the debug log without leaking tokens.
102+
103+
On success, stdout is suppressed (it often contains the access token).
104+
On failure, stdout/stderr are included truncated."""
105+
stderr = (result.stderr or "").strip()[:500]
106+
if result.returncode == 0:
107+
return f"rc=0 stderr={stderr!r}"
108+
stdout = (result.stdout or "").strip()[:500]
109+
return f"rc={result.returncode} stdout={stdout!r} stderr={stderr!r}"
110+
111+
112+
def _scrub_databrickscfg(text: str) -> str:
113+
"""Redact value of any INI key that looks secret-bearing."""
114+
out: list[str] = []
115+
for line in text.splitlines():
116+
stripped = line.lstrip()
117+
if "=" in stripped and not stripped.startswith(("#", ";")):
118+
key = stripped.split("=", 1)[0].strip()
119+
if _SECRET_KEY_PATTERN.search(key):
120+
indent = line[: len(line) - len(stripped)]
121+
out.append(f"{indent}{key} = <redacted>")
122+
continue
123+
out.append(line)
124+
return "\n".join(out)
125+
126+
127+
def _scrub_json(value: object) -> object:
128+
if isinstance(value, dict):
129+
return {
130+
k: ("<redacted>" if _SECRET_KEY_PATTERN.search(k) else _scrub_json(v))
131+
for k, v in value.items()
132+
}
133+
if isinstance(value, list):
134+
return [_scrub_json(v) for v in value]
135+
return value
136+
137+
138+
@functools.cache
139+
def _log_auth_diagnostics() -> None:
140+
"""Dump CLI version, profiles, and ~/.databrickscfg (scrubbed) to the debug log.
141+
142+
No-op unless UCODE_DEBUG=1; cached so it runs at most once per process."""
143+
if not _debug_enabled():
144+
return
145+
146+
try:
147+
version_result = subprocess.run(
148+
["databricks", "--version"],
149+
check=False,
150+
capture_output=True,
151+
text=True,
152+
timeout=10,
153+
)
154+
version = (version_result.stdout or version_result.stderr or "").strip()
155+
_debug("databricks --version", version[:200])
156+
except (OSError, subprocess.TimeoutExpired) as exc:
157+
_debug("databricks --version", f"exception: {type(exc).__name__}: {exc}")
158+
159+
try:
160+
profiles_result = subprocess.run(
161+
["databricks", "auth", "profiles", "--output", "json"],
162+
check=False,
163+
capture_output=True,
164+
text=True,
165+
timeout=10,
166+
)
167+
_debug(
168+
"databricks auth profiles",
169+
f"rc={profiles_result.returncode} "
170+
f"stderr={(profiles_result.stderr or '').strip()[:300]!r}",
171+
)
172+
if profiles_result.returncode == 0 and profiles_result.stdout:
173+
try:
174+
payload = json.loads(profiles_result.stdout)
175+
_debug("profiles json", json.dumps(_scrub_json(payload))[:2000])
176+
except json.JSONDecodeError as exc:
177+
_debug("profiles json", f"decode error: {exc}")
178+
except (OSError, subprocess.TimeoutExpired) as exc:
179+
_debug("databricks auth profiles", f"exception: {type(exc).__name__}: {exc}")
180+
181+
cfg_path = Path(os.environ.get("DATABRICKS_CONFIG_FILE") or "~/.databrickscfg").expanduser()
182+
try:
183+
if cfg_path.is_file():
184+
raw = cfg_path.read_text(encoding="utf-8", errors="replace")
185+
_debug(f"databrickscfg ({cfg_path})", _scrub_databrickscfg(raw)[:4000])
186+
else:
187+
_debug(f"databrickscfg ({cfg_path})", "not present")
188+
except OSError as exc:
189+
_debug(f"databrickscfg ({cfg_path})", f"read error: {exc}")
190+
191+
91192
def _http_get_json(
92193
url: str, token: str, *, timeout: int = 10
93194
) -> tuple[dict | list | None, str | None]:
@@ -230,6 +331,7 @@ def install_databricks_cli() -> None:
230331

231332

232333
def has_valid_databricks_auth(workspace: str) -> bool:
334+
_log_auth_diagnostics()
233335
try:
234336
env = build_databricks_cli_env(workspace)
235337
result = run(
@@ -240,11 +342,16 @@ def has_valid_databricks_auth(workspace: str) -> bool:
240342
env=env,
241343
timeout=15,
242344
)
345+
_debug(
346+
"has_valid_databricks_auth",
347+
_format_subprocess_result(result),
348+
)
243349
if result.returncode != 0:
244350
return False
245351
data = json.loads(result.stdout or "{}")
246352
return bool(data.get("access_token"))
247-
except (json.JSONDecodeError, OSError, subprocess.TimeoutExpired):
353+
except (json.JSONDecodeError, OSError, subprocess.TimeoutExpired) as exc:
354+
_debug("has_valid_databricks_auth", f"exception: {type(exc).__name__}: {exc}")
248355
return False
249356

250357

@@ -312,36 +419,54 @@ def ensure_databricks_auth(workspace: str) -> None:
312419

313420

314421
def get_databricks_token(workspace: str, *, force_refresh: bool = False) -> str:
422+
_log_auth_diagnostics()
315423
env = build_databricks_cli_env(workspace)
316424
cmd = ["databricks", "auth", "token", "--host", workspace, "--output", "json"]
317425
if force_refresh:
318426
cmd.append("--force-refresh")
319427

428+
_debug(
429+
"get_databricks_token.env",
430+
"set="
431+
+ ",".join(
432+
sorted(
433+
k for k in env if k.startswith("DATABRICKS_") or k in {"BUNDLE_PROFILE"}
434+
)
435+
),
436+
)
437+
320438
def _fetch() -> str:
321439
try:
322440
result = run(
323441
cmd,
442+
check=False,
324443
capture_output=True,
325444
text=True,
326445
env=env,
327446
timeout=15,
328447
)
329-
return json.loads(result.stdout or "{}").get("access_token", "")
330-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, json.JSONDecodeError):
331-
return ""
448+
_debug("auth token", _format_subprocess_result(result))
449+
if result.returncode == 0:
450+
return json.loads(result.stdout or "{}").get("access_token", "")
451+
except (subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
452+
_debug("auth token", f"exception: {type(exc).__name__}: {exc}")
453+
return ""
332454

333455
token = _fetch()
334456
if not token:
335457
# Session may have expired — attempt non-interactive re-auth and retry once.
458+
_debug("auth token", "empty on first fetch; attempting auth login --no-browser")
336459
try:
337-
run(
460+
reauth = run(
338461
["databricks", "auth", "login", "--host", workspace, "--no-browser"],
339462
capture_output=True,
463+
text=True,
340464
env=env,
341465
timeout=30,
342466
)
343-
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
344-
pass
467+
_debug("auth login", _format_subprocess_result(reauth))
468+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as exc:
469+
_debug("auth login", f"exception: {type(exc).__name__}: {exc}")
345470
token = _fetch()
346471

347472
if not token:

tests/test_databricks.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@
1111
import ucode.databricks as db_mod
1212
from ucode.databricks import (
1313
AI_GATEWAY_V2_DOCS_URL,
14+
_format_subprocess_result,
1415
_parse_databricks_cli_version,
16+
_scrub_databrickscfg,
17+
_scrub_json,
1518
build_auth_shell_command,
1619
build_databricks_cli_env,
1720
build_opencode_base_urls,
@@ -146,6 +149,102 @@ def test_prefers_databricks_bearer(self, tmp_path):
146149
assert result.stdout.strip() == "bearer-token"
147150

148151

152+
class TestFormatSubprocessResult:
153+
def test_suppresses_stdout_on_success(self):
154+
result = subprocess.CompletedProcess(
155+
args=["databricks", "auth", "token"],
156+
returncode=0,
157+
stdout='{"access_token": "dapi-secret-do-not-leak", "token_type": "Bearer"}',
158+
stderr="",
159+
)
160+
formatted = _format_subprocess_result(result)
161+
assert "dapi-secret-do-not-leak" not in formatted
162+
assert "rc=0" in formatted
163+
164+
def test_includes_stdout_on_failure(self):
165+
result = subprocess.CompletedProcess(
166+
args=["databricks", "auth", "token"],
167+
returncode=1,
168+
stdout="useful diagnostic output",
169+
stderr="error: no matching profile",
170+
)
171+
formatted = _format_subprocess_result(result)
172+
assert "rc=1" in formatted
173+
assert "useful diagnostic output" in formatted
174+
assert "no matching profile" in formatted
175+
176+
177+
class TestScrubDatabrickscfg:
178+
def test_redacts_token_value(self):
179+
text = "[DEFAULT]\nhost = https://example.databricks.com\ntoken = dapi-secret\n"
180+
scrubbed = _scrub_databrickscfg(text)
181+
assert "dapi-secret" not in scrubbed
182+
assert "token = <redacted>" in scrubbed
183+
assert "host = https://example.databricks.com" in scrubbed
184+
185+
def test_redacts_various_secret_keys(self):
186+
text = (
187+
"[p]\n"
188+
"client_secret = secret-val-1\n"
189+
"bearer_token = secret-val-2\n"
190+
"api_key = secret-val-3\n"
191+
"password = secret-val-4\n"
192+
"auth_type = oauth-u2m\n"
193+
)
194+
scrubbed = _scrub_databrickscfg(text)
195+
for secret in ("secret-val-1", "secret-val-2", "secret-val-3", "secret-val-4"):
196+
assert secret not in scrubbed
197+
assert "auth_type = oauth-u2m" in scrubbed
198+
199+
def test_preserves_comments_and_sections(self):
200+
text = "# comment\n[DEFAULT]\nhost = https://x\n; another comment with token = leak\n"
201+
scrubbed = _scrub_databrickscfg(text)
202+
assert "# comment" in scrubbed
203+
assert "[DEFAULT]" in scrubbed
204+
assert "; another comment with token = leak" in scrubbed
205+
206+
def test_key_matching_is_case_insensitive(self):
207+
text = "[p]\nTOKEN = upper\nAccess_Token = mixed\n"
208+
scrubbed = _scrub_databrickscfg(text)
209+
assert "upper" not in scrubbed
210+
assert "mixed" not in scrubbed
211+
212+
213+
class TestScrubJson:
214+
def test_redacts_secret_keys(self):
215+
payload = {
216+
"access_token": "dapi-secret",
217+
"host": "https://example.databricks.com",
218+
}
219+
scrubbed = _scrub_json(payload)
220+
assert isinstance(scrubbed, dict)
221+
assert scrubbed["access_token"] == "<redacted>"
222+
assert scrubbed["host"] == "https://example.databricks.com"
223+
224+
def test_recurses_into_nested_structures(self):
225+
payload = {
226+
"profiles": [
227+
{"name": "DEFAULT", "client_secret": "abc"},
228+
{"name": "other", "password": "pw"},
229+
]
230+
}
231+
scrubbed = _scrub_json(payload)
232+
assert scrubbed == {
233+
"profiles": [
234+
{"name": "DEFAULT", "client_secret": "<redacted>"},
235+
{"name": "other", "password": "<redacted>"},
236+
]
237+
}
238+
239+
def test_passes_through_scalars_and_non_secret_keys(self):
240+
assert _scrub_json("plain") == "plain"
241+
assert _scrub_json(42) == 42
242+
assert _scrub_json({"host": "x", "auth_type": "pat"}) == {
243+
"host": "x",
244+
"auth_type": "pat",
245+
}
246+
247+
149248
class TestGetDatabricksToken:
150249
def _fake_databricks(self, tmp_path, script: str) -> dict:
151250
fake = tmp_path / "databricks"

0 commit comments

Comments
 (0)