Repository navigation
Memoize Databricks CLI tokens per process and skip impossible --no-browser re-auth - #1017
Conversation
…owser re-auth A ug claude launch calls get_databricks_token from many places, and a host whose first-matching CLI profile has a stale refresh token paid fail -> auth login --no-browser -> fail on every call. Current CLIs reject --no-browser, so the re-auth never did anything. Memoize tokens per workspace and resolved profile until five minutes before their earliest stated expiry, or for 60 seconds without expiry. Cache definitive failures briefly, but never cache token-fetch or re-auth timeouts and lock contention. Forced refresh bypasses the memo and does not replace a success with failure. Auth validation shares successful tokens and logins clear the memo. Only attempt non-interactive re-auth when the CLI advertises --no-browser, and skip the second fetch after a failed re-auth. Ported from databricks-eng/universe#2742026 onto Unity Gateway main at 4d8ff8d. Coordinate concurrent fetches per key, reject in-flight memo writes after login invalidation, and cover failed help probes, expiry, refresh, and concurrent callers without changing credential/profile routing. Co-authored-by: Isaac <no-reply@databricks.com> Signed-off-by: Harry Yao <harry.yao@databricks.com>
david-siqi-liu
left a comment
There was a problem hiding this comment.
Thanks for porting this, the latency win is worth having. Two things I'd like addressed before this gets pinned in Isaac, inline below: transient CLI failures getting memoized as definitive, and the monotonic-clock memo deadline across laptop suspend. Both are small fixes.
One more that I couldn't anchor inline because the file isn't in the diff: run_connection_login in src/ucode/mcp_connection_login.py (~L119) runs databricks auth login --resource ... --profile P but doesn't clear the token memo, unlike the other login paths. mcp_proxy's post-login _mint retry is non-forced, so it can get the pre-login token back (e.g. if the browser login switched accounts), and the once-per-session login guard prevents another attempt. Suggest calling clear_databricks_token_cache() in a finally there. Non-blocking.
Require explicit invalid or missing credential diagnostics before negative caching, expire memos when either wall or monotonic time reaches its deadline, and clear the shared token memo after every attempted MCP connection login. Add regressions for transient failure recovery, suspend and clock rollback, login failure paths, and fresh-token use on sync and async proxy retries. Update the component coverage notes. Signed-off-by: Harry Yao <harry.yao@databricks.com>
|
Also addressed the non-inline connection-login note in ef1d269: Validation:
Live Isaac/workspace E2E was not rerun for this feedback update. |
Signed-off-by: Harry Yao <harry.yao@databricks.com> # Conflicts: # src/ucode/databricks.py # tests/test_databricks.py
UG reviewThe change adds process-local credential caching, login invalidation, and capability-gated re-authentication. Long-lived processes still have recovery gaps around rejected cached tokens and transient capability-probe failures. Major
Automated advisory review of |
After this PR, no workflow uses `pull_request_target`. Fork PRs get credentialed CI when an org member (`MEMBER` or `OWNER`) comments a command on the PR. Every comment-triggered run uses the default branch's workflow definitions. Design doc (option B): https://docs.google.com/document/d/1v-6znVBCb8YfGxr12SBd80w3i9n4U0fMEYohyz9Aoyw/edit ## Commands Org members comment these on a PR: - `/integration-test` runs the full Integration workflow with secrets on the PR's current head commit. - `/integration-test <sha>` does the same, but only if the head is still `<sha>` (7 to 40 hex characters). If the fork pushed since, it refuses and replies with the new head. - `/ug-review` runs the UG rubric review. - `/user-journey-check` runs the user-journey gate. For in-repo PRs it also runs automatically, so the command is mostly for forks. Each command posts a commit status and a comment with the run link on the PR. The statuses are `Fork integration` and `User Journey Test Required`. Each new push needs a new command. Anyone else who comments `/integration-test` gets a reply saying only org members can run integration tests. ## Before and after Live integration on fork PRs: - Before: every credentialed job skips, because fork PRs get no secrets on `pull_request`. That covers the E2E CUJs, workspace validation, and the smoke, full, and managed journeys. - After: a member reviews, then comments `/integration-test` or `/integration-test <sha>`. It runs `integration.yml` on the pinned commit with secrets. Live integration on in-repo PRs: - Before: runs automatically. - After: unchanged. `/integration-test` also works as an on-demand rerun, and it never cancels the automatic run. UG review: - Before: adding the `ug-review` label, via `pull_request_target`. - After: a member comments `/ug-review`. It still runs only default-branch code and never executes the PR. User Journey Test Required: - Before: `pull_request_target` on every PR. - After: `pull_request` for in-repo PRs, and `/user-journey-check` for fork PRs. It still reads the PR only through the API. The gate checks the head is unchanged across its diff reads and reports the fork-PR status only on that evaluated commit, so a push during the check never inherits the old verdict. `pull_request_target`: - Before: used by `ug-review.yml` and `user-journey-test-required.yml`. - After: not used anywhere, and a contract test fails if it comes back. No admin event-policy exception is needed before GitHub blocks it on public repos on 2026-11-02. ## Fork safety in `integration.yml` - It takes an optional `ref` input. When the input is empty, existing callers behave exactly as before. - Every checkout pins `ref` and sets `persist-credentials: false`. - setup-uv skips its cache for ref-pinned runs, so fork builds cannot poison it. - Concurrency groups key on the PR number, so runs for different PRs never cancel each other. Concurrency is set per job, so ordinary PR comments never cancel a run. `AGENTS.md` now says workflows must not use `pull_request_target`. The new `fork-integration.yml` call to `integration.yml` carries the same inline `zizmor: ignore[secrets-inherit]` as `ci.yml` (databricks#1089). ## Testing - Contract tests cover the triggers, the member-only gate, and the authorize script, run against a fake `gh`. They also check the non-member reply, the pinned credential-free checkouts, the disabled caches, and the status reports. - `issue_comment` workflows only run from the default branch, so the first live run happens after merge. The plan is to comment `/integration-test` on a fork PR such as databricks#1017 or databricks#1018, and `/ug-review` on any PR. OIDC note: under `issue_comment`, the OIDC subject is the default branch, not a PR ref. If the JFrog trust policy for the Windows installation lane keys on PR refs, that one lane fails on fork runs. The other lanes use secrets, not OIDC. This pull request and its description were written by Isaac. --------- Co-authored-by: Isaac <no-reply@databricks.com>
|
/integration-test |
…icks#1101) After this PR, `/integration-test` works on fork PRs. Its three small jobs (authorize, non-member reply, result report) run on `databricks-protected-runner-group`, the same runners `ug-review.yml` and the user-journey gate already use. ## Before and after `/integration-test` comment: - Before: the authorize job fails right away with HTTP 403 ("the `databricks` organization has an IP allow list enabled"). GitHub-hosted `ubuntu-latest` runners are not on the org allow list, so their GitHub API calls are rejected. Nothing replies on the PR and no status is posted. First seen on databricks#1017. - After: the jobs run on allowlisted runners, so the command posts the `Fork integration` status, runs Integration on the pinned commit, and reports back. Reading the PR head in the authorize step: - Before: `gh api` piped into a standalone `jq`. - After: one `gh api --jq` call. The protected runner uses a custom image (`ubuntu-22-04-crowdstrike-image`) that does not guarantee a `jq` binary, and gh has jq built in. ## Testing - `tests/test_integration_contract.py` (72 passed). The fake `gh` in the authorize-script test answers only the exact `--jq` query the step sends, so changing that expression fails 6 cases. A new test fails if any fork-integration job loses the protected runner group or its `linux-ubuntu-latest` label (a misspelled label fails it), goes back to `ubuntu-latest`, or calls a bare `jq`. - Read-only probe on `databricks-protected-runner-group` (scratch branch, since deleted): `gh` 2.102.0 is installed, and the exact `gh api --jq` line read databricks#1017's head (`f2db89b`, `yaoharry/unity-gateway`) through the IP allow list with the job token. - The rest of the chain is unchanged and already proven. The failed databricks#1017 run shows the member gate admits a `MEMBER` comment. The `integration` call grants the same permissions as `ci.yml`'s. A fork head commit is fetchable by SHA from this repo. Windows JFrog OIDC accepts the `refs/heads/main` subject that `issue_comment` runs get, as every main push run shows. - `issue_comment` workflows only run from the default branch, so the end-to-end check is commenting `/integration-test` on databricks#1017 after this merges. This pull request and its description were written by Isaac. --------- Co-authored-by: Isaac <no-reply@databricks.com>
|
/integration-test |
|
Fork integration failure on f2db89b: https://github.com/databricks/unity-gateway/actions/runs/38296038771 |
|
Fork integration success on f2db89b: https://github.com/databricks/unity-gateway/actions/runs/38296038771 |
What did you change, and why?
Why: On a host whose first-matching
~/.databrickscfgprofile has an invalid refresh token, everyget_databricks_tokencall paidauth token(fail) ->auth login --no-browser->auth token(fail). Current CLIs (v1.17.0 and v1.19.0) reject--no-browserwithError: unknown flag: --no-browser, so the re-auth never did anything.Change:
get_databricks_tokenmemoizes per process, keyed by (workspace, resolved profile). A minted token is reused until 5 minutes before itsexpires_in/expiry, taking the earlier of the two. A token whose JSON has no expiry is reused for 60s. A definitive failure is memoized for 60s and re-raised with the same message, including the stale-profilelogout/loginhint. Timeouts and token-cache lock contention are not memoized.force_refresh=Trueskips the memo read and never stores a failure, so a failed forced retry cannot hide a token other callers still use. Nothing persists across processes. TheDATABRICKS_BEARER/DATABRICKS_BEARER_COMMANDshort-circuits are unchanged.has_valid_databricks_authshares memoized successes, not failures, so the decision to offer an interactive login always uses a real check after a cached failure. Regular and custom-OAuth CLI logins clear the memo.--no-browserre-auth runs only when a successfuldatabricks auth login --helpprobe lists the flag. The probe runs lazily, once per CLI binary per process, only after a token fetch fails. If help or re-auth fails, the unnecessary login or second token fetch is skipped.Why the stale generic profile is used at all (not changed here)
This is not a fallback order. The two profiles serve different callers:
configure_shared_statealways setsstate["profile"]withfind_profile_name_for_host, which returns the first CLI profile whose host matches. That happens even in custom-OAuth CLI mode (ENABLE_CUSTOM_OAUTH_FROM_CLI=1), and in the measured setup it picks the stale profile.ug-oauth-<host>-<client-id>) is stored separately instate["custom_oauth"]["profile"]. Only these paths use it: Claude'sapiKeyHelper(what the gateway actually sees), the preflight probe inconfigure_shared_state, smart routing's_launch_token, and the web-search MCP (agents/claude.pyalready prefers it for the web-search profile).refresh_managed_config(managed_config.py), which usesstate.get("profile"). MCP discovery, skills download and similar callers use the same source. When that token fails,refresh_managed_configquietly falls back to the persisted config, or to none. So a workspace's admin-published managed config is currently not applied for a user whose generic profile is stale, even though their custom-client login is healthy.Changing which credential these callers use has auth and governance implications, so this PR leaves it alone.
Open question for reviewers: which of these, if any, should a follow-up do? I don't have enough context on the intended identity and scope model for custom OAuth clients to choose.
state["profile"]callers (managed config, MCP discovery, skills). This matches the identity the gateway already sees, removes the remaining stale call (~115ms), and makes managed config apply. Risks: those workspace APIs would be called with a token from a different OAuth app, with whatever--scopesthe user chose. Narrower scopes would turn into 403s on MCP/UC/skills. If the two profiles belong to different principals, the caller's identity changes. A narrower variant applies this only torefresh_managed_config.How do you know it works?
Verification on current
main(4d8ff8d):tests/test_databricks.py,tests/test_mcp_web_search.py,tests/test_custom_oauth.py, andtests/test_integration_contract.py, including strengthened threaded regressions.uv run --frozen pytest -qresult is 3,396 passed,42 skipped, 4 failed. The same four installed-agent capture tests fail on
unmodified
main(3,375 passed, 42 skipped, 4 failed): both the User-Agentand managed-header cases for Claude and Codex in
test_e2e_user_agent.py.This host's Claude rejects its machine-wide model selection, and its Codex
uses the configured gateway rather than the local capture server. No new
full-suite failures were introduced.
main:three stale-profile calls produce six token fetches instead of one, and a
repeated successful fetch returns a new token instead of the memoized token.
ty check src/, andgit diff --checkpass.The original author measured the original patch on the earlier base
3108c95witha stale generic profile before a healthy custom-client profile (Databricks CLI
v1.19.0). Their handoff reports 9 -> 3
databricks authsubprocesses, 593ms -> 152msCLI wall time, and 1.89s -> 1.59s median time to the final
claudeexec. Thesemeasurements have not been repeated here.
This pull request and its description were written by Isaac.