feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth - #5545
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.
8af6afb to
679e604
Compare
b02451d to
aef6216
Compare
…h-coordinator * origin/main: feat(desktop): add isolated named demo builds (#6407) fix(model-capabilities): humanize databricks goose model names (#7135) feat(db): add NIP-FI identity and final-admission schema foundation (#6994) feat(buzz-acp): give each channel thread its own agent session (#6732) docs: add review-proven failure-path & async-state rules to AGENTS.md (#7061) fix(desktop): back split thread headers (#7137) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
The test must exercise reconciliation, not the fast-path cache. With Z in state and Z != rejected, cached_hit returns Z before the joiner path is reached. Fix: hold state during acquire so the fast-path try_lock misses, forcing the joiner branch. The reconciliation try_lock also fails (state still held), which is the correct behavior — another task holds state, so adoption is skipped and Z is preserved. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…place synthetic tests Replace try_lock with lock().await in both joiner state transitions: - Success: reconcile B's state under the lock to guarantee the write completes before returning. try_lock's skip-on-contention could leave stale or empty state and recreate the P1 regression on the next plain bearer() call. - Matching failure: expire B's matching rejected state under the lock for the same reason. The joiner holds neither the INFLIGHT registry mutex nor the cross-process file lock at this point, so awaiting state cannot deadlock. Fix the unnecessary_map_or Clippy diagnostic: use is_none_or. Remove the ~310-line joiner_reconciliation_tests module (private-seam synthetic scaffolding). Replace with real public-API coordinator tests that drive two independently constructed sources through actual leader/joiner acquisition and verify subsequent reads: - test_inprocess_joiner_reconciles_stale_state_after_shared_success (Unix): B holds locally-fresh-but-rejected X; after shared success Y, subsequent plain bearer() on B returns Y, not X. Mutation: bearer-only publication leaves B.state=unexpired-X; next read returns X. - test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure (Unix): B holds X; after matching shared RefreshRejected, next bearer() cannot return X. Mutation: no expire_rejected on Err path leaves B.state=X. - test_inprocess_joiner_populates_empty_state_no_second_acquisition (non-Unix): empty A and B join a browser flow; B's subsequent headless read returns Y and no second browser opens. Mutation: bearer-only leaves B.state=None; headless returns NoCredential (no disk fallback on non-Unix). Add test_joiner_preserve_distinct_newer_credential to auth::tests: real concurrent write pattern — Z is written to B's state while B is in slot.wait(); after waking, reconciliation predicate correctly preserves Z. Mutation: unconditional adoption overwrites Z with Y. Also update test_joiner_shared_failure_recovers_disk_replacement_under_state_contention: rename and remove the now-wrong held-mutex framing (holding state from outside and calling lock().await in the same task would deadlock). Fix MINOR marker-comment overclaims: both snapshot-marker comments now state that the marker proves B captured generation 0 before A records generation 1, not that it proves B is queued/waiting behind A. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Adds test_joiner_reconciliation_blocked_until_state_lock_released, a focused regression that holds B's state mutex across slot publication, proving the joining future cannot return before reconciliation completes. The test exercises the real acquire() joiner path: B's fast-path try_lock fails (mutex held), B sprints to slot.wait(), Y is published while the mutex is still held, and B's state.lock().await suspends. is_finished() asserts B has not returned. Releasing the mutex lets B complete; the subsequent public acquire() returns Y from the in-memory state, not stale X. Mutation check (lock().await → try_lock()): try_lock fails while the mutex is held, the adopt block is skipped, B returns immediately (is_finished() == true fails the assertion), and the subsequent read returns stale X — the exact P1 regression from the Carl review. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Changes requested: one P2 regression
Reviewed head 81216c80c55772bee34ccf5f848ee913bb6b7a82 against base 4a9de1a3a121285ef475d630b2b5764044c02cde. This is a convergence-focused re-review of acquisition coordination. The prior full-credential joiner-retention/neutralization finding and process-denial snapshot-barrier finding are addressed. The new failure cleanup introduces the following durable-state regression.
P2: Keep joiner failure cleanup from writing the cache outside the auth-file lock
Location: crates/buzz-agent/src/auth.rs:937-940.
The matching-failure joiner holds only its own state mutex, but calls expire_rejected, which also reads and rewrites the shared disk cache (lines 587-605). The shared auth-file lock has already been released by the leader; it does not fence this joiner. The equality check and subsequent atomic rename are not a compare-and-swap.
Source-derived Unix interleaving:
- A and B are independent same-key sources in one process, both recovering rejected X. A leads, expires disk X under the file lock, then fails. B receives the matching shared failure.
- B reads the expired disk X inside
expire_rejected; it still matches X, so B prepares another write. A later explicit acquisition C, in another process, holds the auth-file lock and successfully persists Y with its new refresh token. - B's unfenced rename commits after C's rename, replacing Y with its expired-X snapshot and old refresh token. The in-place/remove fallbacks are unfenced as well.
Impact: a successful sign-in loses its durable credential. Fresh sources miss Y, retry the old refresh token, and can fail headlessly or require another interactive sign-in when that old token is invalid. B's disk recovery check also misses the replacement it just overwrote. This is introduced by the new joiner helper call, not an inherited concern or Windows persistence follow-up.
Smallest safe repair: separate conditional in-memory invalidation from durable invalidation. Keep the joiner's local-state reconciliation, but ensure every credential-content write/removal remains owned by the existing cross-process lock; do not reuse the combined disk-mutating helper from a lockless joiner. For normal completed attempts the leader already handles disk invalidation. Do not assume every shared error proves that step ran: lock-acquisition failures return before it. Add a deterministic witness for failed A/joining B versus successful C that asserts C's full persisted credential survives and B cannot serve rejected X.
Closed findings and nonblocking coverage note
- Full
CachedTokenpublication, awaited conditional adoption, matching rejected-memory invalidation, and preservation of distinct usable Z address the previous P1. Headless, Auto, and UserInitiated remain separated; non-Unix same-process retention is covered without requiring disk persistence. - The worker snapshot marker is emitted after capturing the attempt generation and observed before releasing the predecessor. The earlier process-denial timing issue is closed. Unix disk-test gates and the Windows CI crash-release description are corrected.
- Nonblocking:
test_joiner_shared_failure_recovers_disk_replacement(auth.rs:2316-2364) now installs a usable disk replacement before acquisition without state contention.acquirereturns it at its initial fast path, so the test passes without exercising shared-failure recovery. Arrange the replacement after the caller has actually joined, and make removal of that recovery branch fail the test. The new lock/nonoverwrite witnesses also rely onyield_now()as a scheduling barrier (auth.rs:2923-2939,3032-3052); use explicit polling to Pending or a boundary acknowledgment instead of claiming one yield guarantees the child reached the intended await.
Coverage/limits: checked the public acquisition entrypoints through cache/refresh/browser producers, leader publication, independent joiner state, subsequent reads, rejected-identity and intent boundaries, process generation adoption, persistence ownership, cancellation, and changed test/CI wiring. Read exact-base governing/product documents; integrated all assigned review lanes. Desktop wiring, runtime 401 integration, and Windows secure persistence/cross-process success handoff remain excluded by the agreed contract. Source-only: no checkout, build, tests, or PR code execution; the race above is established by source ordering, not a claimed runtime reproduction. CI success is not asserted.
- Introduce `expire_rejected_memory`: in-memory-only variant of `expire_rejected`, used by the matching-failure joiner to neutralize its own state without touching the shared disk cache. The combined disk-mutating helper is reserved for `acquire_locked`, which runs under the cross-process file lock. - Replace the spawned-task / yield_now / is_finished joiner test with direct manual polling of a pinned `acquire()` future using `Waker::noop()`. Poll 1 structurally proves B reached `slot.wait()`; poll 2 while the state mutex is held proves the production `lock().await` parks (Pending) while the rejected `try_lock` mutation returns Ready immediately, failing the assertion. - Convert `test_joiner_preserve_distinct_newer_credential` to the same direct-poll approach, removing the `yield_now` / `tokio::spawn` scheduling assumption. - Rewrite `test_joiner_shared_failure_recovers_disk_replacement` to force the joiner path (hold state guard, poll 1 proves joiner reached `state.lock().await`), then install the disk replacement before releasing, ensuring the test exercises the joiner recovery branch rather than the initial fast-path `cached_hit`. - Add `test_joiner_failure_does_not_write_disk`: seeds X on disk, runs B as a matching-failure joiner, and asserts the disk file is byte-for-byte unchanged. Mutation (expire_rejected_memory -> expire_rejected) reads the disk file, overwrites with `expires_at=0`, and fails the byte-equality assertion -- proving the unfenced write would overwrite any concurrent process C write. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Fixes clippy::empty_line_after_doc_comments (-D warnings) on the expire_rejected / expire_rejected_memory doc block boundary at auth.rs:559. No behavior change. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…est comment The "both layers" Rustdoc was attached to expire_rejected_memory while expire_rejected had no doc. Move it above expire_rejected where it belongs; leave only the memory-only contract and LockTimeout limitation on expire_rejected_memory. In test_joiner_preserve_distinct_newer_credential, replace "real concurrent write" with "intervening write" to accurately describe the same-task Z installation via direct polling (no spawn involved). No behavior change; comments only. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Context::from_waker takes &Waker; Waker::noop() already returns &'static Waker, so passing &waker was a double-reference caught by clippy::needless_borrow. Remove the redundant & at all four test sites. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
wesbillman
left a comment
There was a problem hiding this comment.
Carl, an automated reviewer, commenting via Wes’s GitHub account.
Review clear: prior blocker resolved
Reviewed exact head e1c89528e7526edabe8cf506fe7bf1cadd3ce35a against base 4a9de1a3a121285ef475d630b2b5764044c02cde. No actionable blocker remains in this corrective review of the agreed acquisition-only contract.
- Lockless cache overwrite repaired. The matching-failure joiner now awaits its local state and calls memory-only invalidation before a filtered disk read (auth.rs:978–985). That path cannot rewrite/truncate/remove credential contents. Combined durable invalidation remains in
acquire_locked, reached under the auth-file guard (1029–1085); refresh/browser persistence retains the same ownership. The sole P2 blocker in my previous review is closed. - Regression witnesses now reach production boundaries. The revised tests directly poll real
acquirefutures: disk recovery happens after joining, disk contents remain unchanged on shared failure, reconciliation cannot complete while state is locked, and distinct usable Z survives shared success Y (2364–2555, 3066–3263). The disk-invariance test is a no-content-write witness, not a three-process race execution. Independent source audit agreed; no mutation tests were executed. - Established boundaries preserved. Headless/Auto/UserInitiated separation, full-credential sharing, rejected-identity checks, conditional joiner adoption, generation matching, and cooldown ownership remain intact. A pre-lock
LockTimeoutdoes not guarantee durable invalidation; the documented possibility of a later plain read re-adopting disk X is not certified away by this repair.
Scope: re-reviewed the auth.rs delta from the last published head 81216c80, including the subsequent documentation and four waker-borrow corrections, using prior whole-acquisition review evidence for unchanged paths. Read exact-base governing/product guidance and integrated the independent test lane. Desktop Phase 2 wiring, runtime 401 integration, and Windows secure persistence/cross-process success handoff remain deferred; non-Unix same-process credential retention remains in scope.
Validation is source-only: no checkout, build, test, live workflow, or PR-code execution. No current CI success is asserted. This is a clear COMMENTED review, not an approval.
* origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) Signed-off-by: John Tennant <jtennant@squareup.com> # Conflicts: # crates/buzz-db/src/runtime/migration.rs
…c-agent-commit-identity * origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # .github/workflows/ci.yml
* origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) Hide download action on voice notes (#7182) ci: run PostgreSQL tests in isolated lane (#6730) Add voice notes to desktop messages (#6978) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…rding-v3 * origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) Hide download action on voice notes (#7182) ci: run PostgreSQL tests in isolated lane (#6730) Add voice notes to desktop messages (#6978) Signed-off-by: Clay Delk <clay.delk@gmail.com>
…agent-edit * origin/main: feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) feat(buzz-auth): add production NIP-FI federated assertion runtime (#7109) Hide download action on voice notes (#7182) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> # Conflicts: # desktop/src/features/agents/AGENTS.md
…n-surface * origin/main: feat(desktop): add Pi agent preset (#7208) feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…-history * origin/main: fix(acp): replace real user name in base prompt mention example (#7250) ci: split CI into reusable workflows (#7168) fix(desktop): retain automatic mentions only in threads (#7144) feat: add databricks fable 5.1 model capabilities (#7213) docs(nip-fi): rewrite NIP-FI as stateless OSS Buzz spec v2 (#7214) feat(relay): add detailed readiness metrics (#7149) feat(desktop): add Pi agent preset (#7208) feat(buzz-agent): add DatabricksAuthCoordinator single-flight OAuth (#5545) fix(desktop): preserve keyring identity during recovery (#7203) feat(mobile): prepare `buzz-push-gateway` for deployment (#7158) ci: relax file-size ceilings by surface (#6485) fix(mobile): isolate extension linker flags; complete iOS build in CI (#7187) chore(ci): lower Codex security review effort (#7179) fix(dev-mcp): extend shell timeout cap to 20 minutes and align outer budgets (#7185) fix(dev): keep the canonical profile when launching from desktop/ (#7143) Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
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. Across processes, callers serialize on aflock-based advisory lock and share success through the on-disk cache. RAIIDropreleases both lock and leader slot.SlotPublishcarries the fullCachedTokenon success. Each joiner reconciles its own independentstatecell understate.lock().awaitbefore returning: adopt when absent, expired, or matching the joiner's rejected credential; preserve any distinct newer usable credential. On a matching shared failure, neutralize the joiner's in-memory rejected entry under lock viaexpire_rejected_memory— durable disk mutation is reserved foracquire_lockedunder the cross-process file lock. Without this, a joining source's state remains stale or empty and subsequent plainbearer()calls resurface the rejected or absent credential.finish()is the candidate-token persistence boundary for refresh and browser results. Before a token is written to cache or the cooldown is cleared, a bearer equal to the caller's rejected bytes yields a typed failure.acquire_lockedenters withrejected = Some(bytes), it callsexpire_rejected()under the state lock before any cache check.AttemptRecordsidecar records a monotonically-increasing generation, intent, result code, and SHA-256 digest of the completing caller's rejected token. Adoption is temporal (pre-queue snapshot predates current generation) and digest-matched.AuthErrorwith stablecode()/from_code()replaces display-text matching.persist()is a no-op. Lock, cooldown, and attempt sidecars are active on all platforms. Tests that seed or assert on the on-disk token cache are#[cfg(unix)]-gated.Tests
crates/buzz-agent/tests/databricks_auth_coordinator.rs: browser/cooldown/classification acceptance matrix with a scriptedBrowserOpenerand stub OIDC provider. P1 regressions exercise the fullfinish()→acquire_locked()→acquire_leader()→LeaderGuard::complete()→ joiner wiring:test_inprocess_joiner_reconciles_stale_state_after_shared_success(Unix): two real sources both loaded locally-fresh-but-rejected X; after shared success Y, subsequent plainbearer()on both returns Y, not X.test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure(Unix): B's matching rejected X is force-expired in memory after sharedRefreshRejected; subsequent read cannot return X.test_inprocess_joiner_populates_empty_state_no_second_acquisition(non-Unix): empty A/B join a browser success; B's subsequent headless read returns Y without a second browser (no disk fallback on non-Unix exposes the regression).test_crossprocess_userinitiated_waiter_adopts_predecessor_denial(snapshot-marker barrier replacing an earlier sleep for deterministic generation ordering).auth.rsin-crate tests: lock-primitive edges, disk-recheck on shared failure (#[cfg(unix)]), and:test_joiner_reconciliation_blocked_until_state_lock_released: deterministic direct-poll proof that awaited reconciliation requiresstate.lock().await. The test task holds B's state mutex and manually polls a pinned realacquire()future — Poll 2 (slot published, mutex still held) must returnPendingbecauselock().awaitblocks; withtry_lockinstead, Poll 2 returnsReady, failing the assertion.test_joiner_preserve_distinct_newer_credential: deterministic direct polling parks B atslot.wait(), then writes Z directly into B's state in the same task, then publishes Y and awaits completion. B must return Y but leave state == Z. Mutation check: unconditional adoption overwrites Z with Y, failing the state assertion.test_joiner_shared_failure_recovers_disk_replacement(Unix): the matching-failure joiner enters the recovery branch — afterexpire_rejected_memory(in-memory, empty state no-op) it reads a sibling-written disk replacement viausable_from_diskand returns it. Mutation check: removing theusable_from_diskrecovery branch returnsErr(RefreshRejected).test_joiner_failure_does_not_write_disk(Unix): byte-for-byte disk-invariance regression — a matching-failure joiner callsexpire_rejected_memoryand must not touch the on-disk cache. An independent process C may write a valid replacement between A's failure and B's reconciliation; this guard ensures B's unfenced in-memory neutralization cannot overwrite C's concurrent disk write. Mutation check: reverting toexpire_rejectedrewrites the file (expires_at = 0), changing the bytes and failing the assertion.test_lock_timeout_leaves_cooldown_sidecar_byte_for_byte_untouched: a waiter past its deadline returnsLockTimeoutbefore enteringacquire_locked; the cooldown sidecar bytes are unchanged. Documents a pre-lock limitation:LockTimeoutcallers do not neutralize state or sidecars.Scope / follow-ups
crates/buzz-agent/.windows-sysbinding is available.Stack
Built on #5534 (
hayt/databricks-oauth-cache-hardening), now merged. Retargeted tomain.