feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth - #5545
feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth#5545wpfleger96 wants to merge 15 commits into
Conversation
90a4f0b to
c581d2e
Compare
…5607) When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [#5545](#5545) ever merges. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
* fix(buzz-agent): harden Databricks OAuth token cache and callback (block#5534) Hardens the Databricks PKCE OAuth code in `crates/buzz-agent/src/auth.rs`. Two fixes. ## Token cache is owner-only across its whole lifecycle, and race-safe The PKCE cache holds both the access and refresh tokens, but `save()` wrote it with a bare `fs::write` + `fs::rename`. Under a `022` umask the file landed world-readable, and the fixed `*.json.tmp` temp name races across concurrent savers sharing `$HOME` — one writer's `rename` can fail on another's half-written temp. **On write**, `write_private_cache()` creates a temp file with owner-only permissions from the moment it exists — mode `0o600` on Unix via `OpenOptions::mode` — writes and fsyncs it, then renames over the destination. The rename swaps the inode wholesale, so a pre-existing cache file with loose permissions is *replaced* by the new private inode rather than inheriting its mode. `unique_suffix()` (getrandom, timestamp fallback) gives each write a distinct temp name, and a drop guard removes the temp on any failure path. **On load**, owner-only is enforced as a cache lifecycle invariant, not just a write-path property. A world-readable cache left by an older buzz-agent was previously read straight into memory and returned on the fresh cache-hit path without ever invoking `save()`, so a token file with no advertised expiry could stay exposed indefinitely. `read_cache()` now funnels every load — initial and cross-process re-reads — through `read_private_cache()`, which on Unix opens with `O_NOFOLLOW` (kernel-level symlink refusal, no stat/open TOCTOU), requires a regular file, and `fchmod`s the pinned handle to `0o600` when any group/other bit is set. A cache that cannot be secured is treated as absent, so callers fail closed to a fresh flow rather than trusting an exposed file. ## OAuth callback no longer reflects untrusted input The localhost callback embedded the untrusted `error` query param straight into the HTML response — an XSS sink on the redirect page — and routed that same raw value into the error string that reaches the logs. `callback_outcome()` is now a pure function returning `(result, static_page)`: the browser always sees a fixed literal page that embeds no request parameter, and failure detail travels only through the result channel. `sanitize_callback_detail()` strips control characters (CR/LF log-line injection) and caps length before that detail enters the error string bound for the logs. ## Deferred: Windows owner-only ACLs Windows owner-only protection is out of scope for this change. The goose-parity route (`CreateFileW` with an owner-only SDDL `D:P(A;;FA;;;OW)`) requires `unsafe` FFI, which this crate's `#![forbid(unsafe_code)]` prohibits; reconciling that conflict is a separate decision. Both platform seams — `create_private_temp_file` (write) and `read_private_cache` (load) — have a `#[cfg(not(unix))]` branch that relies on the default per-user ACLs and is the drop-in point if Windows protection is added later. No new dependency and no `unsafe` are introduced here. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> (cherry picked from commit 5e4d0fe) * fix(desktop): launch Databricks OAuth from passive model discovery (block#5607) When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [block#5545](block#5545) ever merges. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> (cherry picked from commit 1ff98fa) * fix(relay): stop panicking the ingest worker on reactions to project events (block#5294) A NIP-25 reaction whose target is a project root or project comment (kind 1621 issue, 1618 PR, or a kind-1 comment on one) carries no h tag, so channel_id is None on the reaction write path. The conformance-trace emission asserted a channel was always present: channel: channel_label(channel_id.expect("reaction path has channel")), so the worker panicked at ingest.rs:2824. The row was inserted before the panic, so the client saw a failed request for a persisted event and retried, and the duplicate branch carried the same expect, head-of-line blocking a durable publish queue forever. Mirror the message write's three-way split at the same seam: (Some, true) -> WriteInsert, (Some, false) -> WriteDuplicate, (None, _) -> WriteInsertGlobal. The conformance vocabulary already models channel-less writes; only the reaction path was missing it. Closes block#4936 Signed-off-by: Taksh <takshkothari09@gmail.com> Signed-off-by: Ravneet Arora <rarora@squareup.com> (cherry picked from commit 16b7ae7) * Harden shared agent instruction review (block#4220) ## Summary - render shared-agent instructions as literal text so Markdown cannot conceal spoiler contents, link destinations, or image sources - reject non-reviewable Unicode controls at every agent-definition boundary while preserving legitimate rendered emoji sequences - verify shared catalog event IDs and signatures before trusting authorship, coordinates, pagination, or executable content - preserve the exact system-prompt bytes between review and execution instead of silently stripping or normalizing content ## Security rationale Shared system prompts are executable configuration. Previously, catalog prompts were projected through the chat Markdown renderer, which could hide text, replace link destinations with benign labels, and turn image syntax into remote loads. Zero-width and bidirectional controls could also make reviewed text differ from what the agent executes. This change establishes a review invariant: the prompt a user sees is the prompt the agent executes. Definitions that cannot be reviewed faithfully are rejected rather than rewritten. Catalog events must also pass Nostr ID/signature verification before they can claim a publisher, coordinate, or cursor. ## What changed - catalog instructions render as exact literal text rather than rich Markdown - catalog relay events are verified on a fresh wire-shaped object before paging, coordinate selection, attribution, or projection - forged content, pubkeys, signatures, and invalid newer heads are ignored and cannot shadow a valid signed definition - TypeScript catalog parsing rejects unsafe remote definitions before they reach the UI - shared Rust validation covers persona create/update/import, inbound relay sync, definition-less managed-agent sync, and catalog publication paths - definition-less managed agents now fail closed on local create, local update, and publication before persistence or relay retention - linked managed agents validate their local name while treating the persona definition as authoritative; their inert record-level prompt is not executed or published - names reject layout controls; prompts retain ordinary newlines and tabs - legitimate emoji composition is supported, including contextual VS16, ZWJ, skin-tone, family, flag, and keycap sequences - detached selectors/joiners, bidirectional controls, tag characters, zero-width concealment, and other default-ignorables remain rejected - names are bounded to 128 characters and prompts to 64 KiB - contributor guidance documents the byte-for-byte review requirement for future sharing paths Validation reports the offending code point and never silently removes it. ## E2E recording [buzz-shared-agent-security-e2e.webm](https://github.com/user-attachments/assets/44d6b75f-0877-490f-bda4-a716fae3f700) The recording demonstrates: - a safe definition remains visible - a prompt containing zero-width `U+200B` is rejected - a name containing bidi override `U+202E` is rejected - the prompt is preserved exactly - spoiler, link, and image syntax remains literal and does not render or load ## Verification Passed locally: - `just test`: all 10 unit and Docker-backed integration stages - desktop frontend unit suite: 4,295 tests - persona catalog relay unit suite: 32 tests, including forged-event and cursor-shadowing cases - focused Rust definition-validation coverage: 3 local create/update tests and 6 publication-filtered tests - complete desktop Tauri library suite after rebase: 2,263 passed, 14 ignored, 0 failed - desktop Tauri clippy with warnings denied and Rust formatting - complete agent Playwright spec: 34 tests - the exact formerly failing `inbox-edit` immediate-attachment smoke test after rebase: 1 test - focused shared-agent publish, literal-review, hidden-control, signature, and cross-member import Playwright coverage - desktop E2E production build and TypeScript typecheck - changed-file formatting/lint and file-size ratchet - pre-commit secret scan and DCO signoff The branch was rebased onto current `main`, which includes the upstream attachment-button label fix. Fresh post-rebase GitHub CI is green for every required and selected check: Desktop Core, all four Desktop Smoke E2E shards, both Desktop E2E Integration shards and their aggregate, Desktop E2E Relay, Desktop Build (macOS), Windows Rust, Rust Lint, DCO, security scanners, and Desktop Release Candidate. The previously failing `Desktop Smoke E2E (3)` shard now passes. The repository-wide desktop check also reports existing CSS formatting/`!important` findings in `components.css` and `terminal.css`; neither file is changed by this PR. GitHub's Desktop Core lint and format stage passes on the rebased branch. --------- Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> (cherry picked from commit a96af89) * fix: reject non-reviewable Unicode formatting * fix: close upstream security review gaps * fix: authorize inbound agent sync events --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Taksh <takshkothari09@gmail.com> Signed-off-by: Ravneet Arora <rarora@squareup.com> Signed-off-by: Alex Rosenzweig <arosenzweig@squareup.com> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Hayt <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Taksh Kothari <takshkothari09@gmail.com> Co-authored-by: Alex Rosenzweig <64241648+shellz-n-stuff@users.noreply.github.com>
…lock#5607) When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [block#5545](block#5545) ever merges. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Signed-off-by: bhargavms <bhargav.m@ewa-services.com>
…lock#5607) When a user's agent runtime is `buzz-agent` with no cached Databricks OAuth token, the desktop app's passive model-discovery surfaces were forbidden from launching interactive auth. Discovery failed silently, so the model dropdown showed only built-in fallback models behind a vague "Could not load live models for `databricks_v2`" note (reported internally by Nick and Jose). ## What changed Both discovery surfaces — the passive draft-form discovery and the explicit saved-model picker — now launch the browser OAuth flow, matching goose's behavior. The only behavioral difference between them is cooldown handling: - **Passive draft discovery** fires on every form-state change, so a failed, cancelled, or timed-out sign-in records a per-host cooldown (5 min) that suppresses re-popping the browser on the next keystroke. While the cooldown is active it returns the "sign-in required" guidance instead of relaunching. - **The explicit model picker** is a deliberate user action, so it always launches and clears any stale cooldown first. Safety rails: - A 150s hard timeout (`AUTH_FLOW_TIMEOUT`) bounds the whole interactive flow so an abandoned SSO tab fails discovery cleanly rather than wedging the dropdown. Success clears the cooldown; failure and timeout both record it. - `AuthCooldown` recovers from a poisoned lock rather than wedging every future sign-in on one panic. The frontend maps the terminal Databricks sign-in states to typed, actionable copy in `formatModelDiscoveryErrorStatus`: "sign-in required" is a muted note pointing at the picker and `buzz-agent auth databricks`; a failed or timed-out sign-in is a warning pointing at the explicit retry. Other Databricks failures fall through to the existing generic notice. ## Scope Changes are confined to Databricks discovery and its frontend status formatter — no `agent_models.rs` call sites are touched. The interactive-auth helper takes an injected timeout so the timeout/cooldown policy is unit-testable without a live browser. ## Deferred Cooldown keys use the raw trimmed `DATABRICKS_HOST`, while the catalog and OAuth cache normalize trailing slashes (`crates/buzz-agent/src/catalog.rs:96`, `crates/buzz-agent/src/llm.rs:2046`). So `https://workspace/` and `https://workspace` share credentials but get separate cooldown entries — an equivalent-spelling change to the host field mid-cooldown can re-pop passive OAuth once within the 5-minute window. Self-limiting (one extra browser launch, never auth corruption). Follow-up: a `trim_end_matches('/')` on the cooldown key plus an equivalent-host test, picked up with the coordinator migration if [block#5545](block#5545) ever merges. Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes required at e58204b88601af14460f17940d2bc3d008f78b49:
-
[P1] Preserve
UserInitiatedretry semantics across in-process coalescing.AutoandUserInitiateduse the same(lock_path, may_open_browser = true)slot, and a joiner returns the leader’s result without applying its own intent (auth.rslines 675–699). If an explicit user action joins anAutoleader that returns an active cooldown at lines 802–807, the user receives the priorDenied/TimedOutresult instead of clearing the cooldown and opening sign-in asUserInitiatedpromises. Key the slot by the relevant intent policy, or make a user-initiated joiner retry when it inherited an automatic cooldown result. Add the mixed-intent race test; the current tests cover same-intent coalescing and sequential cooldown bypass only. -
[P1] Do not accept an expired replacement after a 401. In
cached_hit,rejected = Some(t)considers any token whose bytes differ fromtusable (lines 603–623), without checkingis_expired. An expired in-memory or on-disk token B can therefore be returned as the presumed sibling replacement for rejected token A, skipping refresh forrefresh_nowand every public rejected-token acquisition. A replacement must differ from the rejected token and still be unexpired. -
[P1] Separate authorization-code rejection from exchange infrastructure failure. The code-exchange path maps every non-success status, including 429 and 5xx, plus malformed 2xx JSON/token payloads, to
ExchangeFailed(lines 1544–1556). That variant is terminalLlmAuthand cooldown-worthy, so a transient provider outage after callback is reported as a rejected code and suppresses automatic auth for five minutes. Classify transport/429/5xx and malformed success responses asNetworkUnavailable; reserveExchangeFailedfor an OAuth response that establishes the authorization grant was rejected. Mirror the refresh classifier’s status/body coverage in exchange tests.
I reviewed the public entry-point × intent × cache/refresh/browser/cooldown matrix, same-process and cross-process coordination, cancellation/deadline behavior, OAuth classification, and platform paths using pinned GitHub source only. I did not execute PR code.
54cbda2 to
4513d23
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes required at 4513d2385fcd4e09109c99756f83c309ea3346b9:
- [P1] Include rejected-token identity in same-process single-flight.
cached_hitcorrectly makes the caller'srejectedbearer part of cache validity, butInflightKeycontains only(lock_path, AuthIntent)and a joiner returns the leader's result without applying its own rejected value (auth.rslines 656–707 and 1061–1132). This breaks the public 401/403 retry contract when concurrent requests reject different bearer generations. For example, whilerefresh_now(A)is refreshing,refresh_now(B)joins the sameHeadlessslot; if the leader publishesB, the second request receives the exact token it just reported rejected and retries its provider call with known-bad credentials. The inverse can also inherit a terminal refresh/network result even though that caller's cached replacement was already valid. Include a non-secret digest ofrejectedin the slot key, or revalidate a joined result against the waiter's rejected value and rerun its own cache/acquisition policy. Add a deterministic concurrent different-rejected-values test proving a waiter never receives its rejected bytes.
The three blockers from the previous head are fixed: full AuthIntent now separates mixed cooldown policy, rejected replacements must differ and be unexpired, and exchange infrastructure failures remain NetworkUnavailable. I reviewed public entry points, cache/refresh/browser/cooldown transitions, same/cross-process coordination, cancellation/deadlines, OAuth classification, platform cache paths, and the test matrix using pinned GitHub source only. Exact-head CI is otherwise green; I did not execute PR code.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
[P1] Do not persist a bearer after proving it equals the caller’s rejected token.
The new equality guard runs in acquire_leader only after acquire_locked returns (crates/buzz-agent/src/auth.rs:786-810). Both successful live-token paths call finish first (:845, :906), and finish atomically saves the token to disk and memory before returning it (:921-930). If a provider reissues the exact bearer just rejected with 401/403, this call correctly returns RefreshRejected or NetworkUnavailable, but leaves that known-bad bearer cached as fresh. The next ordinary bearer() calls acquire(Headless, None) and cached_hit accepts it because no rejected identity is supplied (:605-623, :943-946). The runtime can therefore restore and send credentials this flow already proved unusable; a fresh process does the same from disk.
Validate the candidate before committing it, or explicitly invalidate/remove the persisted candidate on equality without destroying a still-valid concurrent replacement. Add lifecycle regressions for both sticky refresh and sticky browser exchange: after the rejected-aware acquisition fails, a following bearer() and a newly constructed token source must not return the rejected bytes.
The prior different-rejected-joiner race itself is fixed: joiners revalidate the published result and perform a bounded rerun. This remaining blocker is the persistence boundary after that rerun.
Read-only exact-head source review; no PR code was checked out or executed.
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested at exact head 5cdc56bae2ece14fdacce7f8c0de064eaab5ffa6:
-
P1: rejected-token reissuance still leaves the original known-bad cache entry live. The new
finishguard correctly refuses to persist a newly issued token equal torejected, but it does not invalidate the existing cached token. In the real lifecycle,bearer()returns cached unexpiredA, the provider rejectsA, andrefresh_now(A)receives stickyAfrom refresh.finishreturnsRefreshRejectedbefore saving, leaving the original unexpiredAin memory and on disk. The next plainbearer(None), or a fresh process, acceptsAagain. The new poison tests seedexpired-seedwhile passing a different rejected value (sticky-token/sticky-browser), so they cannot detect this path. Add regressions that seed the same unexpiredApassed asrejected, then prove the failure cannot be followed by a cache hit forA; invalidate conditionally so a concurrently persisted distinct replacement is preserved. -
P1: the file lock serializes cross-process failures but does not single-flight them for
UserInitiatedorHeadless. After process A fails and releases the lock, an already-waiting process B entersacquire_locked. AUserInitiatedwaiter clears A's just-written cooldown and opens a second browser; aHeadless401 waiter repeats the failed refresh. The process-localINFLIGHTslot cannot publish A's failure across processes. The current denial test only uses anAutowaiter, whose cooldown policy masks the gap; success shares through the cache. Preserve an attempt generation/outcome so callers already queued behind that generation can adopt failures while later explicit user retries still bypass cooldown, and add real two-process denial and failed-refresh regressions. -
P1 security: Windows persistent OAuth cache files are not owner-only. The non-Unix read path accepts any cache ACL unchanged, and the non-Unix temp-file path creates cache/cooldown files with inherited default ACLs. These files hold access and refresh tokens; the implementation comments explicitly defer the owner-only DACL. On a permissive parent or pre-existing broad ACL, other local principals can read persisted credentials. Create and validate/repair an owner-only Windows DACL, or disable persistent OAuth caching there until that guarantee exists. The Unix path's
O_NOFOLLOW, fd-based mode repair,0600creation, and atomic rename do not cover Windows.
The previous exact-head blocker about persisting the newly returned rejected token before validation is partly fixed by moving equality validation ahead of save; item 1 is the remaining full-lifecycle gap. CI's normal build/test/security gates are green; the Codex security-review job was cancelled. Review was read-only; no PR code was executed.
5cdc56b to
d0dded8
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested on exact head 8af6afb3c8f9164dfd6cc765d5416f70c9d174b6.
-
P1: Do not share rejected-token-relative failures across different rejected bearers. The in-process slot key contains only
(lock_path, intent)(crates/buzz-agent/src/auth.rs:780-840). If Headless caller A rejectsXand refresh reissuesX,finishreturnsRefreshRejected; concurrent caller B rejectingYinherits that error, although the successful refresh proved the grant remains live andXdoes not equal B’s rejected value. The cross-process attempt record has the same problem because it stores intent/result but not rejected identity (:958-963, 1253-1262). Existing tests cover the inverse shared-success collision where the leader returns the joiner’s rejected bytes, not a leader-relative failure. Key rejection-relative outcomes by a non-secret rejected-token digest, or rerun joiners when the shared result may not satisfy their rejected identity; cover both in-process and cross-process transitions. -
P1: An adopter must not create a new attempt generation. When a queued process adopts a predecessor’s terminal failure,
acquire_lockedimmediately callswrite_attemptagain (:958-963). That records work which never ran. A caller arriving after the real attempt can snapshot generation 1, queue behind the adopter that advances it to 2, and incorrectly adopt the old failure; overlapping arrivals can relay it indefinitely. Return the adopted error without advancing the sidecar, and add a three-process regression where C arrives after A’s failure but while B is adopting it. -
P1: The non-Unix memory-only policy breaks the cross-process success contract and its newly required Windows CI lane.
persistis a no-op andread_private_cacherefuses/deletes disk tokens on non-Unix (:512-527, 1578-1595), so a Windows waiter serialized byLockFileExcannot consume the winner’s bearer and performs another refresh/browser flow. Exact-head Windows CI proves this:test_crossprocess_two_coordinators_race_to_one_grant_and_cachegetsbrowser-token-2versusbrowser-token-1, and the added auth-coordinator step fails 20 of 33 tests. Either provide secure Windows success handoff, or explicitly scope the product contract and Windows tests to invariants that remain true without persistence. Do not leave a required platform lane structurally red. -
P2: Rejected-token neutralization is not fail-closed for a readable stale file.
expire_rejectedrewrites best-effort and then removes best-effort (:544-570). If both fail while the original token file remains readable, such as an owner-writable cache file in a now read-only directory where temp creation and unlink fail, a fresh process still serves the locally-unexpired bearer already proven dead. The regression substitutes a directory at the cache path, which the read path rejects independently, so it does not cover this case. Add a fallback that durably expires/removes the readable regular file or make subsequent loads reject it, with the actual double-failure regression.
The OAuth status classification, Unix no-follow/0600/atomic cache path, bounded lock/browser lifetimes, public intent routing, and prior rejected-token persistence fixes otherwise look coherent. The unrelated Desktop smoke layout failure is not attributed to this PR. Review was read-only; PR code was not executed.
Databricks OAuth acquisition was scattered across the TokenSource methods with no coordination: concurrent callers (Desktop discovery, the model picker, managed-runtime inference) could each pop their own browser, and a just-denied attempt would immediately re-prompt. Consolidate every acquisition path behind one coordinator on PkceOAuthTokenSource. - Intent policy (Auto/UserInitiated/Headless) decides browser and cooldown behavior; all four TokenSource methods plus interactive_login route through one acquire()/acquire_locked() core. - Single-flight per cache key via a std File advisory lock. try_lock is per open-file-description, so distinct handles contend in-process AND across processes — one primitive covers both with no in-memory registry. RAII drop releases, so a crashed holder never wedges a successor. - Typed AuthError outcomes with stable code()/from_code() for the Phase 2 Tauri boundary, replacing display-text matching. - Durable cooldown sidecar: every failed browser attempt is recorded; Auto honors an unexpired record instead of re-launching, UserInitiated bypasses and clears it. Success clears it. - Browser opener injected and invoked while the localhost callback listener is live, so a launch failure never returns a URL pointing at a torn-down listener. The crate now uses std File::try_lock/unlock (stable in 1.89), so its rust-version is pinned above the 1.88 workspace floor. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Round-2 fixes to the DatabricksAuthCoordinator: - Rejected-aware acquisition: `acquire_with_intent(intent, rejected)` lets the saved-picker recovery path replace a locally-fresh bearer the server just 401'd, so Auto/UserInitiated escalate to a browser instead of re-returning the dead token. `refresh_now`'s hardcoded Headless could not. - Typed `RefreshOutcome`: only a token-endpoint grant rejection becomes RefreshRejected/browser-fallback; transport/timeout/5xx/decode failures stay NetworkUnavailable so a transient fault never pops a browser. - In-process shared-future joiner (INFLIGHT/InflightSlot/LeaderGuard): a pre-existing joiner receives the leader's SAME failure result rather than acquiring the lock afterward and launching a second browser. - Deadline holes closed: the HTTP client build error propagates (no untimed fallback), and every interactive timeout exits through the common outcome writer so TimedOut is recorded in the cooldown sidecar under the held lock. - Real cross-process tests: a `lock-holder` child bin takes the advisory lock so single-flight and crash-release are proven across processes, not simulated with same-process handles. buzz-agent added to the Justfile test-unit lane so CI executes these tests. - MSRV: use fs2::FileExt instead of std File::try_lock/unlock (1.89+), restoring the crate to the repo's declared 1.88 floor. The refresh-timeout test injects a short real-time HTTP timeout rather than pausing the clock: under start_paused tokio auto-advances into the timer while the real loopback discovery call is still in flight, tripping the timeout on the wrong request. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ss auth tests Refresh classification now keys on the OAuth error body, not the bare HTTP status class. Per RFC 6749 §5.2 only `error == "invalid_grant"` means the refresh token is dead — the one failure a browser sign-in can repair. Every other 4xx (`invalid_request`, `invalid_client`, `unsupported_grant_type`, `invalid_scope`, 408, 429), an unparseable error body, and all 5xx stay in the infrastructural bucket as `NetworkUnavailable`, so a rate limit or a misconfigured request can no longer pop a needless browser. Prove the cross-process single-flight contract with a real second process. The in-memory `INFLIGHT` registry coalesces same-key callers within one process before the file lock, so two in-process handles cannot exercise the cross-process protocol. A new `auth-worker` test binary runs the public coordinator API against a shared temp cache and a scripted opener, driven by barrier-marker files, covering (a) a `UserInitiated` denial in one process shared with an already-waiting `Auto` in another and (b) two coordinator processes racing to one grant and one cache artifact. Rename the two tests that falsely claimed to be cross-process to reflect the in-process single-flight they actually exercise. Run the coordinator integration suite on the Windows CI job so `LockFileEx` contention and crash release execute rather than compile only. Gate the first provider response in the steer fold test so round 1 cannot complete until the steer is sent and observed accepted, removing a nextest-scheduling race in which round 2's boundary could drain an empty steer queue before the steer was dispatched. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Remove three tests whose guarantees are covered by strictly stronger existing tests, and collapse two near-duplicate cases, without changing any production code or what the suite proves: - Drop the same-process denial-sidecar read test (proven cross-process by test_crossprocess_userinitiated_denial_shared_with_waiting_auto). - Drop the UserInitiated rejected-fresh-bearer case (behaves identically to the retained Auto case here; the load-bearing contrast is the retained Headless variant). - Drop the RAII lock-drop test (subsumed by the real-process crash-release test that kills an actual holder). - Merge the 429 refresh classifier case into the invalid_request test as a second table row, preserving the no-OAuth-body distinction. - Deduplicate the gated fake-LLM helper by threading an optional gate through the shared connection loop instead of copying its HTTP handler. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Carl's re-review found three P1 correctness gaps in the Databricks auth coordinator, each a case where an intent's promised behavior or a transient fault was mishandled. 1. Mixed-intent coalescing keyed the in-process single-flight slot by browser capability alone, so a `UserInitiated` joiner could inherit an `Auto` leader's cooldown-suppressed `Denied`/`TimedOut` instead of the cooldown bypass and fresh browser it promises. Key the slot by the full `AuthIntent` so intents with different outcome policy never share a slot. 2. After a 401, `cached_hit` accepted any token whose bytes merely differed from the rejected one — including an expired sibling — skipping the refresh the 401 demanded. A replacement must differ AND be unexpired. 3. The code-exchange path mapped every non-success status (429, 5xx) and malformed 2xx bodies to terminal `ExchangeFailed`, poisoning the 5-minute cooldown on a transient provider outage after callback. Mirror the refresh classifier: only a 4xx `invalid_grant` is a rejected grant; transport/429/5xx/malformed-2xx are `NetworkUnavailable`. Each fix ships the test Carl asked for; all fail against the pre-fix source and pass after. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The code-exchange transport branch (send().await -> NetworkUnavailable) had no regression test: ExchangeMode only modeled Succeed/Fail/malformed. Add a Hang variant that outlasts a short injected HTTP timeout and assert the exchange surfaces NetworkUnavailable, opens exactly one browser, and leaves no cooldown so a second Auto caller launches its own browser. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
… waiter's rejected token The in-process INFLIGHT slot is keyed by (lock path, intent) only, so a 401-recovery joiner shares a leader that ran with a different rejected value. A joiner could therefore be handed the exact token it just reported 401-rejected (leader's cache re-read/refresh landed on that generation), and retry the provider with known-bad credentials; the inverse could inherit a terminal failure though a sibling had already written a valid replacement. On wait(), a joiner now rejects a token equal to its own rejected bytes and runs its own bounded, leader-eligible acquisition (the slot is evicted before publish, so this is a fresh attempt, not a re-join or a loop), and re-checks the cache before adopting a shared failure. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The 401-recovery joiner path had two lossy cases. On a shared leader failure the recheck used state.try_lock(): when several waiters wake together a try_lock loser skipped the disk read and inherited the terminal error even though a sibling had written a valid replacement. Replace it with a lock-free read_cache so every waiter recovers the replacement, and so the read cannot serialize behind a new leader holding state across its ~60s browser flow. The joiner's bounded rerun (and a plain leader) also returned the refresh result unchecked: a provider that re-issues the identical access token would hand back the exact bytes the caller reported 401-rejected. Guard the refresh-success choke point so a token equal to rejected fails with a typed error (RefreshRejected headless, NetworkUnavailable interactive) instead of escaping or looping. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The rejected-token guard covered only the refresh-success arm, so an interactive acquisition whose dead refresh fell through to a browser sign-in could complete auth and still return the exact bearer the caller reported 401-rejected — the same hole for a plain leader and a colliding joiner's bounded rerun. Move the check to acquire_leader, validating the Ok(token) of acquire_locked against rejected once. That is the single point every successful acquisition returns through — cache, refresh, and browser — so it covers all paths for both leaders and reruns; the refresh-arm guard is now redundant and removed. A match fails typed (RefreshRejected headless, NetworkUnavailable interactive) with no loop: finish() has already cached the token, but the next recovery passes the same rejected, so cached_hit excludes it and forces a fresh attempt rather than serving it back. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The CI clippy config (Windows Rust, Rust Lint) rejects then() with a closure that only moves a value; then_some is the idiomatic form here — the value is a plain field move with no side effects. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The r5 guard validated the recovered token in acquire_leader, after acquire_locked's finish() had already persisted it and cleared the cooldown. A provider re-issuing the exact rejected bearer thus cached the proven-dead token as fresh: the recovering caller got its typed failure, but the next plain bearer() (rejected = None) or a fresh process reading the same cache served the dead token straight back. Move the invariant into finish() itself, the single persistence boundary every live token flows through. A refresh- or browser-issued token equal to the caller's rejected bytes now fails typed (NetworkUnavailable interactive / RefreshRejected headless) before it is written or the cooldown is cleared, so the cache is never poisoned. The redundant post-hoc guard in acquire_leader is deleted; cached_hit and usable_from_disk already exclude rejected, so the two live-token sites are the only paths that can produce the bytes. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…ess failure adoption, Windows disk disable P1-1: A locally-unexpired cached access token reported 401-rejected was not neutralized — the persistence-boundary guard in finish() refused to save a re-issued rejected token, but the original live copy remained in memory and on disk. A later plain bearer() (rejected = None) or a fresh process would serve the proven-dead bytes via is_expired's clock-only check. expire_rejected(), called under the state lock at the top of acquire_locked, force-sets expires_at to 0 on both the in-memory cell and the on-disk cache when their access token byte-equals the reported rejected value, while leaving the refresh token intact. Conditional on byte-equality so a sibling's concurrently-written distinct replacement is preserved. P1-2: Two separate processes both queued on the cross-process file lock did not share failures — when process A held the lock and got RefreshRejected or Denied, process B acquired the lock after A and re-ran the full flow from scratch (second dead-refresh call, second browser). Adds an AttemptRecord sidecar (.attempt file alongside the cache) that records a monotonically-increasing generation, intent, and result code on each completed slow-path attempt. A caller snapshots the current generation before queueing on the file lock; on acquiring it, if the generation advanced and the recorded intent matches and the result is a recognized terminal failure, it adopts that failure rather than re-running. UserInitiated callers never adopt — they always run their own attempt (fresh browser, cooldown bypass), mirroring the in-process INFLIGHT contract. Two cross-process regressions: headless dead-refresh adoption (exactly one refresh grant across two racing processes) and UserInitiated non-adoption (the waiter opens its own browser rather than inheriting the predecessor's denial). P1-3: On non-Unix the token cache write path (write_private_cache) creates files with default ACLs rather than owner-only DACL. Disable on-disk token persistence on non-Unix by making persist() a #[cfg(unix)] no-op. Memory-only cache is correct and safe until a Windows DACL implementation exists; lock and cooldown sidecars hold no secrets and are unchanged. Cost is re-auth per process on Windows. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fix 1 (P1-2 adoption contract): The cross-process adoption contract is
temporal, not intent-based. Remove the `UserInitiated` exclusion at the
adoption site — a caller whose pre-queue snapshot is older than the current
generation was already queued while the predecessor ran and adopts its
same-intent failure, including UserInitiated, mirroring the in-process
INFLIGHT registry that coalesces same-intent UserInitiated callers within
one process. A post-failure arrival naturally has a current-generation
snapshot and does not adopt without any special case. Invert the
cross-process UserInitiated regression: both queued workers receive Denied
with one total browser launch. Add a companion post-failure-arrival test
showing a UserInitiated source arriving after the failure runs its own
attempt.
Fix 2a (P1-2 refresh-arm attempt record): The RefreshOutcome::Refreshed
arm returned self.finish() without recording the attempt when finish()
failed typed (rejected-equal reissuance). A queued headless process would
repeat the sticky refresh instead of adopting. Record recognized terminal
finish() failures before returning.
Fix 2b (P1-2 stale snapshot in writers): All write_attempt call sites
passed the caller's pre-queue snapshot_gen rather than the current
under-lock generation. An intervening different-intent attempt that
advanced the sidecar between snapshot and lock-acquire would cause the
next attempt to rewrite the same generation, making its own queued waiters
see no advance and re-run. write_attempt now reads the current on-disk
generation itself so every completed attempt strictly advances the value.
Drop the generation parameter from write_attempt and update all call sites.
Fix 3 (P1-1 fail-closed): expire_rejected swallowed disk rewrite failures,
leaving an unexpired-but-401'd token on disk for later plain bearer() or
fresh sources. On persist failure, best-effort remove the cache file. If
removal also fails the entry stays, but read_private_cache's O_NOFOLLOW +
type check refuses non-regular-file entries, and the in-memory layer is
always neutralized unconditionally. Two regressions: one proving the
removal path fires (persist fails via EISDIR, directory removed), one
proving in-memory neutralization when disk is unreachable.
Fix 4 (P1-3 read path): Update the non-Unix read_private_cache to retire
legacy token files left by older builds rather than serving them. Adds a
platform-gated regression: on Unix the seeded token is served (expected);
on Windows the legacy file is not consumed and no new file is created.
Narrative: update AttemptRecord doc (write coverage, temporal contract,
drop 'every attempt writes' overclaim), write_attempt doc (reads current
gen), acquire_locked doc (temporal adoption, UserInitiated included),
finish() doc ('candidate-token persistence boundary'), expire_rejected doc
(removal fallback). generation field: 'strictly increasing'.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…jected-token digest P1 (Fix 1): rejection-relative failures are now scoped to the specific rejected token that triggered them. The in-process InflightSlot publishes the leader's rejected-token SHA-256 digest alongside the result; a joiner whose own digest differs reruns via acquire_leader rather than inheriting a failure that is only valid for the leader's specific rejected bytes. Cross-process: AttemptRecord gains a rejected_digest field (SHA-256 hex, serde(default) for backward compat). Adoption requires digest equality (both-None matches) in addition to generation-advance + intent-match + terminal-code. P1 (Fix 2): Adoptors no longer write a new attempt record. Re-writing would advance the generation, causing a post-adoption arrival to see no further advance and run its own attempt, then a fourth caller inheriting the re-written record — relaying the corpse indefinitely. Removed the write_attempt call at the adoption site and documented why. P1 (Fix 3): All disk-seeding tests gated #[cfg(unix)]. Windows CI now exercises lock serialization, cooldown+attempt sidecars (secret-free), and the legacy-file deletion path. The non-Unix contract is documented in the save() doc: cross-process success handoff requires the on-disk cache, so each process performs its own acquisition on non-Unix; failure adoption still works via the attempt sidecar. P1/P2 (Fix 4 — convergent): expire_rejected disk neutralization is now fail-closed with a three-stage fallback: (1) atomic rewrite via persist(), (2) in-place truncating overwrite via OpenOptions::write().truncate(true) on the existing file (succeeds even when the parent directory is non-writable — only the file's own mode matters for writing an existing 0600 file), (3) remove_file as last resort. The primary hostile case — a 0600 file under a 0500 parent — is covered by stage (2). The vacuous regression (directory-at-cache-path, never reached the fallback) is replaced with a real test: readable regular token file under a 0500 parent, atomic rewrite fails, in-place write succeeds, fresh source re-validates over the network. Updated expire_rejected doc and PR body to match. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
8af6afb to
679e604
Compare
…s tests Four targeted fixes, all test/CI closure — production logic unchanged. Fix-1 P1-1 regression: chmod cache_file.parent() (token_dir/databricks), not token_dir. Assert the protected dir equals cache_file.parent() so a future path-resolution change breaks loudly. Pre-create the lock file before the chmod so acquire_auth_lock can open it in a 0500 dir. Rename test: ...neutralizes_in_place_when_parent_blocks_rewrite. Mutation check: deleting the in-place stage makes the test fail (atomic persist now fails too, because the direct parent is 0500). Fix-2 three-process regression (adopt does not relay failure to C): test_crossprocess_adopter_does_not_relay_failure_to_third_process. B queues during A (LAUNCHED_MARKER + PROCEED_MARKER), A fails (gen=1), B adopts without re-writing (gen stays 1). Phase-5 sidecar check asserts gen==1 — FAILS when the deleted write_attempt is restored (gen becomes 2). C arrives post-failure, runs its own browser flow, succeeds. Cache-free, runs on Windows. Fix-3 digest test made deterministic: swap SucceedSticky for HangThenSucceedSticky(300ms). Spawn A first, wait for A's READY_MARKER, then spawn B. A holds the lock for >=300ms so B always queues before A finishes. Assertions are now deterministic: A=RefreshRejected, B=ok, refresh_grants==2. Mutation check documented: r8 shape yields B adopts A's failure -> refresh_grants stays 1 -> assertion fails. Fix-4 Windows CI (Rust Lint + Windows Rust): - Remove two unused bindings (worker_a/b in digest test). - Ungate test_crossprocess_userinitiated_waiter_adopts_predecessor_denial and test_crossprocess_post_failure_userinitiated_runs_own_attempt — neither seeds nor asserts token-cache state; both are Windows-valid and prove the attempt-sidecar adoption contract on Windows. - New three-process test (Fix-2) is also cache-free — runs on Windows. - #[cfg(unix)]-gated items (SucceedSticky, HangThenSucceedSticky, ExchangeMode::SucceedSticky, lock_file_path, seed_fresh_rejectable, WorkerOutcome.bearer) are no longer stranded: they're used exclusively in Unix-gated tests, and their own cfg gates suppress dead-code warnings. - Replace stray 'fail-closed' wording in expire_rejected doc with 'bounded three-stage'. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
What
Consolidates all Databricks OAuth acquisition behind one coordinator on
PkceOAuthTokenSource. Every entry point — the fourTokenSourcemethods (bearer,bearer_no_browser,refresh_now,interactive_login) plus the publicacquire_with_intent— routes through a singleacquire()/acquire_locked()core that owns browser and cooldown policy.Before this, acquisition logic was scattered across those methods with no coordination: concurrent callers (Desktop discovery, the saved-agent model picker, managed-runtime inference) could each pop their own browser, and a just-denied attempt would immediately re-prompt on the next passive read.
How
AuthIntent::{Auto, UserInitiated, Headless}decides whether a caller may open a browser and whether it honors the cooldown.Headlessnever browses;Autobrowses but honors an unexpired cooldown;UserInitiatedbrowses and bypasses+clears the cooldown.INFLIGHT) coalesces same-key, same-intent callers onto one leader's attempt before the file lock. The slot key is(lock_path, AuthIntent), so aUserInitiatedsign-in never inherits anAutoleader's result. A joined result is revalidated against the waiter's own contract: if the leader's published rejected-token digest differs from the joiner's own (both-None matches), the joiner reruns its ownacquire_leader— a leader's rejection-relative failure is not valid for a joiner that carried a different rejected token. Across processes, callers serialize on aflock-based advisory lock (fs2::FileExt::try_lock_exclusive, per open-file-description) and share success through the on-disk cache. RAIIDropreleases both lock and leader slot, so a crashed holder never wedges a successor.finish()is the candidate-token persistence boundary for refresh and browser results (cache-hit paths bypass it). Before a refresh- or browser-issued token is written to cache or the cooldown is cleared, a bearer equal to the caller's rejected bytes yields a typed failure — never a loop, never a poison write.acquire_lockedenters withrejected = Some(bytes), it callsexpire_rejected()under the state lock before any cache check. This force-expires the in-memory entry and the on-disk copy when their access token byte-equals the rejected value, so a later plainbearer()or a fresh process cannot serve the proven-dead token. Disk neutralization uses a bounded three-stage fallback: (1) atomic rewrite viapersist()(temp-file + rename); (2) in-place truncating overwrite viaOpenOptions::write().truncate(true)on the existing file (succeeds even when the parent directory is non-writable, since only the file's own mode matters); (3)remove_fileas a last resort. Our cache files are created 0600, so stage (2) covers the proven hostile case (0600 file under 0500 parent). The refresh token stays intact for recovery.AttemptRecordsidecar (.attemptfile alongside the cache key) records a monotonically-increasing generation, intent, result code, and the SHA-256 digest of the completing caller's rejected token (non-secret;Nonewhen no rejection). A caller snapshots the generation before queueing; on acquiring the lock, it adopts a recorded terminal failure only when (a) the generation advanced, (b) the intent matches, (c) the result is recognized, and (d) therejected_digestmatches its own (both-None matches). Digest mismatch triggers a normal attempt — a rejection-relative failure recorded for token X is not adopted by a waiter that carried a different rejected token. Adopters do NOT re-write the attempt generation: re-writing would relay the failure indefinitely through chained adopters. The adoption contract is temporal: a caller whose pre-queue snapshot predates the current generation was already queued during the attempt and adopts — includingUserInitiatedcallers, mirroring the in-process INFLIGHT coalescing. AUserInitiatedcaller arriving after the failure snapshots the new generation and naturally runs its own attempt.write_attemptreads the current on-disk generation at write time so each completed attempt strictly advances the value.AuthErrorwith stablecode()/from_code()replaces display-text matching.RefreshRejectedandNoCredentialare reconstructed for the attempt-adoption path in addition to the existing cooldown-worthy variants.Autoreads it and returns the recorded outcome;UserInitiatedbypasses and clears it; success clears it. 5-minute expiry.errorbody: only a 4xxinvalid_grantis a genuine rejection. Transport errors, 429, 5xx, and malformed success bodies are infrastructural (NetworkUnavailable) and never poison the cooldown or pop a needless browser.read_private_cachereturnsUnsupportedwithout reading (and best-effort removes any legacy token file left by older builds).persist()is a#[cfg(unix)]no-op. Lock, cooldown, and attempt sidecars hold no secrets and are active on all platforms — cross-process failure adoption works on Windows. Cross-process success handoff requires the on-disk token cache, so on non-Unix each process performs its own acquisition (serialized by the lock, failure-adoption intact) until owner-only DACL persistence exists. Tests that seed or assert on the on-disk token cache are#[cfg(unix)]-gated; Windows CI exercises lock serialization, cooldown + attempt sidecars, and the legacy-file deletion path.Tests
crates/buzz-agent/tests/databricks_auth_coordinator.rs: the browser/cooldown/classification acceptance matrix driven through the public API with a scriptedBrowserOpenerand a stub OIDC provider. Covers single-flight coalescing, cooldown adoption,UserInitiatedbypassingAutodenial, 401-recovery rejecting re-issued tokens, joiner with a different rejected token reruns rather than inheriting a rejection-relative failure (both in-process and cross-process), cross-process lock contention + crash release + single-grant + cache race (real second processes vialock-holderandauth-workerhelper binaries), cross-process failure adoption (bothHeadlessandUserInitiatedqueued workers adopt with matching digest; post-failure arrivals and mismatched-digest waiters run their own attempt), token neutralization (in-place fallback for non-writable parent — proven case), and refresh/exchange classification.auth.rsin-crate tests: lock-primitive edge cases needing private access — contended waiter times out withLockTimeout, RAII drop releases successor, lock-free disk recheck on shared failure.Scope / follow-ups
crates/buzz-agent/.windows-sysbinding is available; disk persistence re-enables then.Stack
Built on #5534 (
hayt/databricks-oauth-cache-hardening), now merged. Retargeted tomain.