diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fbc6f39015..0dc1720f7b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1014,18 +1014,23 @@ jobs: mobile-swift: name: Mobile Swift runs-on: macos-latest - timeout-minutes: 10 + timeout-minutes: 30 needs: [changes] # Fork divergence: mobile lanes are upstream-only (mac desktop fork). if: github.repository == 'block/buzz' && needs.changes.outputs.mobile == 'true' steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install Flutter dependencies + run: cd mobile && flutter pub get - name: Build run: swift build --package-path mobile/ios/BuzzPushKit - name: Build release run: swift build -c release --package-path mobile/ios/BuzzPushKit - name: Test run: swift test --package-path mobile/ios/BuzzPushKit + - name: Build complete unsigned iOS release + run: cd mobile && flutter build ios --release --no-codesign --no-pub security: name: Security runs-on: ubuntu-latest @@ -1179,6 +1184,20 @@ jobs: # Serial: windows_resolver_tests mutate process-global env # (BUZZ_SHELL/GIT_BASH/SystemRoot) that SharedState::new reads. run: cargo test -p buzz-dev-mcp --target $env:TARGET -- --test-threads=1 + - name: Test (buzz-agent auth coordinator) + # The auth coordinator single-flights on an OS advisory lock, which is + # LockFileEx on Windows; this integration suite drives real second + # processes on the same lock file, so it only exercises the Windows + # lock runtime if it runs ON Windows. Every other job compiles it but + # never executes it. Tests exercised on Windows: lock serialization + # (two coordinators race for the same key), cooldown sidecar sharing + # across processes, attempt-sidecar adoption (UserInitiated waiter + # adopts a predecessor's denial), and the in-process single-flight for + # same-key coalescing. Tests that are UNIX-ONLY and NOT executed here: + # crash-release (flock drop on SIGKILL, guarded by #[cfg(unix)]) and + # cross-process cache success/race (on-disk token handoff, also + # #[cfg(unix)]). + run: cargo test -p buzz-agent --target $env:TARGET --test databricks_auth_coordinator # Smoke-test the new host-prereq contract: Git for Windows (which provides # bash) is available on the runner, a shell command round-trips, and bash # does NOT resolve from System32 (so WSL's launcher is never picked up). diff --git a/.github/workflows/codex-security-review.yml b/.github/workflows/codex-security-review.yml index 68ff0553c07..766dc430b90 100644 --- a/.github/workflows/codex-security-review.yml +++ b/.github/workflows/codex-security-review.yml @@ -222,7 +222,7 @@ jobs: contents: read env: CODEX_MODEL: gpt-5.6-sol - CODEX_REASONING_EFFORT: max + CODEX_REASONING_EFFORT: high CODEX_REVIEW_API_KEY_PRESENT: ${{ secrets.CODEX_REVIEW_API_KEY != '' }} REVIEW_CONTEXT: review-context REVIEW_REPOSITORY: review-target diff --git a/AGENTS.md b/AGENTS.md index 43358172fff..2b376791313 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -688,11 +688,13 @@ The mobile app lives in `mobile/` — a Flutter app using Riverpod + Hooks. over raw `Theme.of(context)` calls. - **Keep widgets small and composable.** One public widget per file; push private sub-widgets (`_Foo`) into sibling `part` files under a - `/` folder rather than growing the page file. Hard ceiling: - **1000 lines/file**, enforced across Desktop, Web, and Mobile by the + `/` folder rather than growing the page file. Mobile's hard ceiling is + **1200 lines/file**, enforced with the other surface-specific limits by the repository-level `just file-size-check` gate (`just check`, CI, and every - pre-push). If the guard trips, **split the file — never bump the limit or add - an override to slip under it.** + pre-push). If an individual file trips the guard, **split the file — never + bump a surface limit or add an override merely to admit that file.** + Deliberate repository-wide policy revisions must update the enforced rules, + tests, and guidance together. - Feature modules must not import from other feature modules — only from `shared/`. - Use `Grid` tokens for spacing, `Radii` for border radius. diff --git a/Cargo.lock b/Cargo.lock index 2ef284bd428..e570946077a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -894,6 +894,7 @@ dependencies = [ "axum", "base64 0.22.1", "dirs", + "fs2", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -3029,6 +3030,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/Justfile b/Justfile index 6b979015c65..5e4cf0b75ae 100644 --- a/Justfile +++ b/Justfile @@ -466,14 +466,21 @@ test-unit: # a regression here silently accepts a tampered or replayed bundle, # so they must fail the gate rather than merely exist. cargo nextest run -p buzz-waker - # buzz-agent model-capabilities corpus: the Rust half of the - # cross-language drift guard. `model_capabilities.rs` embeds - # scripts/model-capabilities.json + scripts/normative-corpus.json via - # include_str! and replays the full locked corpus as pure in-process tests (no - # infra). Enumerated explicitly because nothing in CI runs - # `cargo test --workspace`; without this step a manifest edit that - # diverges Rust from the corpus ships green. - cargo nextest run -p buzz-agent --lib + # buzz-agent: two infra-free concerns run together by executing the + # whole crate (lib + integration tests), because nothing in CI runs + # `cargo test --workspace`, so without this stanza neither the crate's + # library tests nor its integration tests execute remotely. + # * model-capabilities corpus (lib): the Rust half of the + # cross-language drift guard. `model_capabilities.rs` embeds + # scripts/model-capabilities.json + scripts/normative-corpus.json via + # include_str! and replays the full locked corpus as pure in-process + # tests; without it a manifest edit that diverges Rust from the + # corpus ships green. + # * OAuth auth coordinator (lib concurrency matrix + databricks + # integration tests): lock single-flight, cooldown, cross-process + # crash recovery — infra-free via a stub OIDC provider and an + # injected browser opener, no network or Postgres. + cargo nextest run -p buzz-agent # Admin API auth-boundary tests (api::admin in buzz-relay): the NIP-98 # duplicate-tag rejections, the Host/Origin replay-ordering causal pair, # the admin.localhost origin/advertisement/canonical-URL pins, and the diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index cf36111a936..41d9a214bdd 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -283,7 +283,7 @@ Buzz Desktop supports registering any ACP-speaking agent tool as a selectable ru **Tier-1 — compiled-in runtimes** (Goose, Claude Code, Codex, Buzz Agent): have auto-installers, auth probes, and first-class onboarding. Their IDs (`goose`, `claude`, `codex`, `buzz-agent`) are reserved and cannot be overridden. -**Tier-2 — preset catalog** (Cursor, Oh My Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. +**Tier-2 — preset catalog** (Cursor, Oh My Pi, Pi, Grok Build, OpenCode, Kimi Code, Amp, Hermes Agent, OpenClaw): static `HarnessDefinition` entries in `desktop/src-tauri/src/managed_agents/discovery/presets.rs` (`PRESET_HARNESSES`). They are always present in the runtime catalog, PATH-probed for availability, not editable or deletable by the user. Displayed with bundled logos; if not installed, a docs link appears instead. > **Note — OpenClaw:** `openclaw acp` is a Gateway-backed bridge; PATH availability shows "Available" even when the OpenClaw Gateway daemon is not running. This is expected tier-2 semantics (same class as a preset with unconfigured auth). The Gateway URL is configured via `OPENCLAW_GATEWAY_URL` (or the equivalent env var from OpenClaw's docs) — set it in the agent's **env vars** in Edit Agent, not in the definition env (the preset definition carries no env entries). Note that `openclaw acp` executes tools inside the Gateway daemon, not the Desktop process, so Desktop-injected `BUZZ_*` env vars do NOT reach the execution locus unless you also set them on the Gateway's own environment. @@ -327,10 +327,9 @@ Invalid files (bad JSON, unknown id, empty command) are skipped with a warning a To add a new runtime to the tier-2 gallery: 1. **Verify the ACP entrypoint** from the vendor's own documentation — do not rely on a PR description alone. Test with the actual binary. -2. **Add a `HarnessDefinition` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`. Leave `env` empty unless the harness requires a specific env var to enable ACP mode. -3. **Add the preset id to `BUILTIN_IDS`** in `desktop/src-tauri/src/managed_agents/custom_harnesses.rs` so custom JSON files cannot shadow it. -4. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. -5. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. +2. **Add a `PresetHarness` entry** to the `PRESET_HARNESSES` slice in `desktop/src-tauri/src/managed_agents/discovery/presets.rs`. Fill `id`, `label`, `command`, `args`, `install_instructions_url`, `install_hint`, and `underlying_cli` when the command wraps a separately installed CLI. Preset ids are automatically reserved so custom JSON files cannot shadow them. +3. **Add a bundled logo** (64×64 PNG or optimised SVG) to `desktop/public/harness-logos/.png` and add a corresponding entry to `PRESET_LOGOS` in `desktop/src/features/onboarding/ui/RuntimeIcon.tsx`. Record the source and license in `desktop/public/harness-logos/CREDITS.md`. Only bundle a mark whose upstream license permits redistribution; skipping this step is caught by `presetLogos.test.mjs`, which asserts every `PRESET_HARNESSES` id has a mapped logo that exists on disk. +4. Run `cargo test --lib` and `just desktop-typecheck` to verify everything compiles. The built-in `BUILTIN_IDS` set (`goose`, `claude`, `codex`, `buzz-agent`, and all current preset ids) is the reserved namespace; every other id is available for custom harnesses. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 3d4e67d0f55..e0e424f0185 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -20,11 +20,11 @@ use crate::filter::SubscriptionRule; /// /// Sized for slow turns where the agent may go silent on its outer ACP channel /// while running long sub-tools (e.g. a buzz-agent running another agent, or -/// codex/claude doing multi-minute single tool calls). 900s gives 300s of -/// breathing room above the 600s max shell timeout, so legitimate long-running +/// codex/claude doing multi-minute single tool calls). 1500s gives 300s of +/// breathing room above the 1200s max shell timeout, so legitimate long-running /// tool calls don't race the idle deadline. /// Override via `--idle-timeout` / `BUZZ_ACP_IDLE_TIMEOUT`. -pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 900; +pub(crate) const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1_500; /// Default absolute wall-clock cap per agent turn (2 hours). /// Override via `--max-turn-duration` / `BUZZ_ACP_MAX_TURN_DURATION`. @@ -2757,9 +2757,9 @@ channels = "ALL" // ── Idle timeout constant + guard (PR #935) ─────────────────────────────── #[test] - fn default_idle_timeout_is_900_seconds() { + fn default_idle_timeout_is_1500_seconds() { // Lock the constant value so accidental changes are caught. - assert_eq!(DEFAULT_IDLE_TIMEOUT_SECS, 900); + assert_eq!(DEFAULT_IDLE_TIMEOUT_SECS, 1_500); } #[test] @@ -2779,6 +2779,45 @@ channels = "ALL" } } + #[test] + fn budget_ordering_invariant_shell_cap_plus_headroom_fits_within_idle_timeout() { + // Asserts the three-layer budget relationship introduced in PR #7185: + // buzz-dev-mcp MAX_TIMEOUT_MS (1 200 000 ms = 1 200s) + // ≤ buzz-agent BUZZ_AGENT_TOOL_TIMEOUT_SECS default (1 260s) + // < buzz-acp DEFAULT_IDLE_TIMEOUT_SECS (1 500s) + // + // The idle deadline must strictly outlast the agent tool timeout so a + // legitimately long-running tool call is killed by buzz-agent first (at + // 1 260s) rather than the ACP idle watchdog. The 240s gap gives the agent + // time to handle the timeout, emit a response, and reset the idle clock + // before the ACP connection dies. + // + // If any of these constants change the compiler catches the inversion here. + // Cross-crate constants are mirrored as literals; grep for PR #7185 to + // find the authoritative source if you need to update them. + const SHELL_CAP_MS: u64 = 1_200_000; // buzz-dev-mcp MAX_TIMEOUT_MS + const SHELL_CAP_SECS: u64 = SHELL_CAP_MS / 1_000; + const AGENT_TOOL_TIMEOUT_SECS: u64 = 1_260; // buzz-agent BUZZ_AGENT_TOOL_TIMEOUT_SECS default + + const { + // Shell cap must not exceed the agent's per-tool-call timeout. + assert!( + SHELL_CAP_SECS <= AGENT_TOOL_TIMEOUT_SECS, + "shell cap must be <= agent tool timeout" + ); + // Agent tool timeout must be strictly less than the ACP idle deadline. + assert!( + AGENT_TOOL_TIMEOUT_SECS < DEFAULT_IDLE_TIMEOUT_SECS, + "agent tool timeout must be < ACP idle timeout" + ); + // ACP idle timeout must remain below the max turn duration. + assert!( + DEFAULT_IDLE_TIMEOUT_SECS < DEFAULT_MAX_TURN_DURATION_SECS, + "ACP idle timeout must be < max turn duration" + ); + } + } + // --- BUZZ_ACP_ALLOWED_RESPOND_TO gate --- fn parse_allowed_respond_to(raw: &[&str]) -> Result, ConfigError> { diff --git a/crates/buzz-agent/Cargo.toml b/crates/buzz-agent/Cargo.toml index fabf75754e1..b60644bb7b6 100644 --- a/crates/buzz-agent/Cargo.toml +++ b/crates/buzz-agent/Cargo.toml @@ -24,6 +24,24 @@ path = "src/main.rs" name = "fake-mcp" path = "tests/bin/fake_mcp.rs" +# Test-only lock holder: a real second process that takes the coordinator's +# cross-process advisory lock, so the auth tests can prove genuine +# inter-process single-flight and crash-release rather than same-process +# handles. Tiny; only used by the databricks auth integration tests. +[[bin]] +name = "lock-holder" +path = "tests/bin/lock_holder.rs" + +# Test-only auth worker: a real second process that runs the PUBLIC auth +# coordinator API (`acquire_with_intent`) with a scripted browser opener and a +# shared temp cache, so the auth tests can prove the cross-process single-flight +# contract end-to-end — durable cooldown sharing and one-grant/one-cache races +# across a genuine process boundary, not two in-process handles. Only used by +# the databricks auth integration tests. +[[bin]] +name = "auth-worker" +path = "tests/bin/auth_worker.rs" + [dependencies] tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } serde = { workspace = true } @@ -45,6 +63,11 @@ url = { workspace = true } urlencoding = "2" webbrowser = "1" dirs = "6" +# Cross-process advisory file lock (flock on Unix, LockFileEx on Windows) for +# the auth coordinator's single-flight. Kept off std's `File::try_lock` so the +# crate stays buildable on the repo's declared 1.88 MSRV (those std APIs are +# 1.89+). +fs2 = "0.4" [target.'cfg(unix)'.dependencies] nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index 56e62cf9e79..f2d68d4d8cd 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -159,7 +159,7 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_CONTEXT_TOKENS` | `200000` | Provider context window used by the handoff gate. | | `BUZZ_AGENT_MAX_HANDOFFS` | `10` | Max context handoffs per session before falling back to truncation. | | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). | -| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds | +| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `1260` | Per-tool call timeout in seconds | | `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) | | `BUZZ_AGENT_MAX_SESSIONS` | unlimited | Max concurrent ACP sessions. Sessions are cheap; default has no cap. | | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | @@ -326,7 +326,7 @@ The trust boundary is **the operator who launched the agent**. The harness, MCP | Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | | Loop rounds | 0 (unlimited) | `BUZZ_AGENT_MAX_ROUNDS` | | LLM read inactivity timeout | 240 s | `BUZZ_AGENT_LLM_TIMEOUT_SECS` | -| Tool call timeout | 660 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | +| Tool call timeout | 1260 s | `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | ## What This Is NOT diff --git a/crates/buzz-agent/src/auth.rs b/crates/buzz-agent/src/auth.rs index 7ebabccbbbd..0ae34318c27 100644 --- a/crates/buzz-agent/src/auth.rs +++ b/crates/buzz-agent/src/auth.rs @@ -15,19 +15,21 @@ //! captures the redirect, and exchanges the code for a token. Subsequent //! calls hit the cache and silently refresh when expired. +use std::collections::HashMap; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, LazyLock}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use async_trait::async_trait; use base64::Engine; +use fs2::FileExt; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; use sha2::Digest; -use tokio::sync::Mutex; +use tokio::sync::{watch, Mutex}; use crate::types::AgentError; @@ -39,6 +41,219 @@ const TOKEN_REFRESH_LEEWAY: Duration = Duration::from_secs(60); /// We match: any longer and the user has gone to lunch. const BROWSER_AUTH_TIMEOUT: Duration = Duration::from_secs(60); +/// Per-request network timeout for every OAuth HTTP call (discovery, refresh +/// grant, code exchange). Without this, a hung provider connection would stall +/// the caller — and, worse, stall every same-key caller waiting on the +/// cross-process lock this holder owns. +const HTTP_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +/// Longest an in-flight auth attempt can legitimately run: cold discovery +/// (`30s`) + browser wait (`60s`) + code exchange (`30s`), plus a failed +/// refresh (`30s`) ahead of the browser. Rounded to `150s`. A waiter derives +/// its lock-wait bound from this so it never times out ahead of a healthy +/// holder. +const AUTH_ATTEMPT_DEADLINE: Duration = Duration::from_secs(150); + +/// How long a same-key caller waits to acquire the cross-process lock before +/// giving up with [`AuthError::LockTimeout`]. Deliberately longer than +/// [`AUTH_ATTEMPT_DEADLINE`] so a waiter outlasts any legitimate holder rather +/// than timing out mid-flow. +const LOCK_WAIT_TIMEOUT: Duration = Duration::from_secs(165); + +/// Poll interval for deadline-aware lock acquisition. `try_lock` is +/// non-blocking, so we sleep between attempts rather than blocking a worker. +const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); + +/// How long a failed interactive (browser) attempt suppresses automatic +/// re-launch for the same key. Long enough that a spurned dropdown does not +/// re-pop a browser on the next debounced refresh, short enough that a user +/// who fixes the problem is not locked out. +const COOLDOWN_DURATION: Duration = Duration::from_secs(300); + +/// Why an auth acquisition wants a token, which decides whether it may open a +/// browser and whether it honors a cooldown. +/// +/// - [`Auto`](Self::Auto): passive Desktop discovery (create/edit/defaults/ +/// onboarding). May open a browser, but honors an unexpired cooldown and +/// returns its recorded outcome instead of re-launching. +/// - [`UserInitiated`](Self::UserInitiated): an explicit human action — the +/// saved-agent model picker or `buzz-agent auth databricks`. May open a +/// browser and *bypasses* the cooldown (the user asked for it now). +/// - [`Headless`](Self::Headless): managed-runtime inference and provider +/// preflight. Never opens a browser; may consume another attempt's cached +/// success but never becomes the initiator. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AuthIntent { + Auto, + UserInitiated, + Headless, +} + +impl AuthIntent { + /// `true` for the intents permitted to open a browser. + fn may_open_browser(self) -> bool { + matches!(self, Self::Auto | Self::UserInitiated) + } + + /// `true` for the one intent that honors a recorded cooldown on read. + fn honors_cooldown(self) -> bool { + matches!(self, Self::Auto) + } + + /// Stable discriminant for the cross-process attempt sidecar. A queued + /// caller adopts a completed attempt's failure only when the recorded + /// intent matches its own — the durable mirror of the in-process + /// [`INFLIGHT`] registry's `(path, intent)` keying, so a `UserInitiated` + /// caller never inherits an `Auto` attempt's suppressed result across + /// processes any more than it does within one. + fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::UserInitiated => "user_initiated", + Self::Headless => "headless", + } + } +} + +/// Typed result of an auth acquisition. `Ok` carries the bearer; the error +/// arm classifies *why* no token was produced so callers (and, in Phase 2, the +/// Tauri boundary) can branch on a stable code instead of matching display +/// text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AuthError { + /// No cached token, no refresh grant, and the caller may not open a + /// browser (`Headless`). + NoCredential, + /// The user (or provider) rejected the browser authorization. + Denied, + /// The browser flow was not completed within [`BROWSER_AUTH_TIMEOUT`]. + TimedOut, + /// Every browser-launch strategy failed, so the flow never started. + BrowserOpenFailed, + /// An OAuth network call (discovery/refresh/exchange) could not reach the + /// provider or timed out. + NetworkUnavailable, + /// A refresh-token grant was rejected (dead/rotated refresh token) and the + /// caller may not fall back to a browser. + RefreshRejected, + /// The authorization-code exchange itself was rejected by the token + /// endpoint (distinct from a refresh rejection). + ExchangeFailed, + /// Could not acquire the cross-process auth lock within + /// [`LOCK_WAIT_TIMEOUT`]. + LockTimeout, +} + +impl AuthError { + /// Stable machine-readable code. Phase 2 serializes this across the Tauri + /// boundary (the `project_git_merge_error` `{code, message}` precedent) so + /// the Desktop formatter switches on the code, never on display text. + pub fn code(&self) -> &'static str { + match self { + Self::NoCredential => "no_credential", + Self::Denied => "denied", + Self::TimedOut => "timed_out", + Self::BrowserOpenFailed => "browser_open_failed", + Self::NetworkUnavailable => "network_unavailable", + Self::RefreshRejected => "refresh_rejected", + Self::ExchangeFailed => "exchange_failed", + Self::LockTimeout => "lock_timeout", + } + } + + /// `true` for the browser-attempt outcomes worth recording in the cooldown + /// sidecar — the failures that would otherwise re-pop a browser on the + /// next automatic attempt. Non-browser failures (no credential, refresh + /// rejection, lock timeout, network) are not recorded. + fn is_cooldown_worthy(&self) -> bool { + matches!( + self, + Self::Denied | Self::TimedOut | Self::BrowserOpenFailed | Self::ExchangeFailed + ) + } + + /// Reconstruct a recorded outcome from its [`code`](Self::code). The + /// cooldown-worthy variants always round-trip; `RefreshRejected` and + /// `NoCredential` are also reconstructed for the cross-process attempt + /// adoption path. Any other code (a forward-compat sidecar written by a + /// newer buzz-agent) yields `None`, treated as "no active record" rather + /// than a hard failure. + fn from_code(code: &str) -> Option { + match code { + "denied" => Some(Self::Denied), + "timed_out" => Some(Self::TimedOut), + "browser_open_failed" => Some(Self::BrowserOpenFailed), + "exchange_failed" => Some(Self::ExchangeFailed), + "refresh_rejected" => Some(Self::RefreshRejected), + "no_credential" => Some(Self::NoCredential), + _ => None, + } + } + + fn message(&self) -> String { + match self { + Self::NoCredential => { + "no cached Databricks token; run `buzz-agent auth databricks` first".into() + } + Self::Denied => "Databricks authorization was denied".into(), + Self::TimedOut => "Databricks browser authorization timed out".into(), + Self::BrowserOpenFailed => "could not open a browser for Databricks sign-in".into(), + Self::NetworkUnavailable => "could not reach Databricks to authenticate".into(), + Self::RefreshRejected => "Databricks rejected the refresh token; sign in again".into(), + Self::ExchangeFailed => "Databricks rejected the authorization code".into(), + Self::LockTimeout => "timed out waiting for a concurrent Databricks sign-in".into(), + } + } +} + +impl From for AgentError { + /// Map a typed auth failure onto the crate error the [`TokenSource`] trait + /// returns. Auth-decision failures become [`AgentError::LlmAuth`] so the + /// caller's retry loop stops instead of hammering a rejected credential; + /// purely infrastructural failures (network, lock contention) become + /// [`AgentError::Llm`], matching the pre-coordinator classification of a + /// discovery/network error. + fn from(e: AuthError) -> Self { + match e { + AuthError::NetworkUnavailable | AuthError::LockTimeout => AgentError::Llm(e.message()), + AuthError::NoCredential + | AuthError::Denied + | AuthError::TimedOut + | AuthError::BrowserOpenFailed + | AuthError::RefreshRejected + | AuthError::ExchangeFailed => AgentError::LlmAuth(e.message()), + } + } +} + +/// Opens a URL for the interactive browser step. Injected so the PKCE +/// continuation (callback listener, verifier, timeout) stays alive across the +/// launch: the coordinator calls this *while* the localhost listener is +/// bound, so a launch failure never leaves a returned URL pointing at a torn +/// down listener. Desktop (Phase 2) supplies the Tauri opener; the CLI uses +/// [`DefaultBrowserOpener`], which prints the URL and opens the system +/// browser. +pub trait BrowserOpener: Send + Sync { + /// Attempt to present `url` to the user. Returning `Err` means every + /// launch strategy for this opener failed; the coordinator then reports + /// [`AuthError::BrowserOpenFailed`] without waiting on a listener nobody + /// will reach. + fn open(&self, url: &str) -> Result<(), String>; +} + +/// Default opener: print the URL (so a user on a headless box can copy it) +/// and open the system browser. Printing is itself a launch strategy, so this +/// never reports failure — the URL is always visible to the waiting user. +pub struct DefaultBrowserOpener; + +impl BrowserOpener for DefaultBrowserOpener { + fn open(&self, url: &str) -> Result<(), String> { + eprintln!("Opening browser for authentication. If it doesn't open, visit:\n {url}"); + let _ = webbrowser::open(url); + Ok(()) + } +} + /// Asynchronous source of a bearer token. The [`Llm`] calls this per /// request, so impls are expected to be cheap on the cache-hit path. #[async_trait] @@ -124,6 +339,25 @@ struct OidcEndpoints { token_endpoint: String, } +/// Typed result of a refresh-token grant, so the coordinator can separate an +/// actual credential rejection from a transient fault. +/// +/// - [`Refreshed`](Self::Refreshed): a fresh token — success. +/// - [`Rejected`](Self::Rejected): the token endpoint returned an +/// `invalid_grant` error (dead/rotated refresh token). This is the only +/// outcome that becomes [`AuthError::RefreshRejected`] for `Headless` or +/// drives a browser fallback for interactive intents. +/// - [`Network`](Self::Network): transport error, timeout, 5xx, any 4xx that +/// is not `invalid_grant` (e.g. `invalid_request`, `invalid_client`, 429), +/// an unparseable error body, or an undecodable/malformed success body — +/// infrastructural or misconfiguration, never a credential decision, so it +/// surfaces as [`AuthError::NetworkUnavailable`] and never pops a browser. +enum RefreshOutcome { + Refreshed(CachedToken), + Rejected, + Network, +} + /// PKCE OAuth token source with on-disk refresh cache. /// /// First call: @@ -137,27 +371,93 @@ pub struct PkceOAuthTokenSource { cfg: PkceOAuthConfig, http: Client, cache_path: PathBuf, - /// Single-flight guard: only one refresh/browser flow at a time, even - /// if many tool calls land concurrently. + /// Injected browser launcher, called inside [`browser_pkce_flow`] while the + /// localhost listener is live. Production uses [`DefaultBrowserOpener`]; + /// Phase 2 supplies the Tauri opener. + opener: Arc, + /// In-memory single-flight *and* fast-path cache. The cross-process file + /// lock serializes slow-path work; this cell keeps the fast path off disk + /// during a turn and off the lock entirely. state: Mutex>, } impl PkceOAuthTokenSource { + /// Construct with the default browser opener (prints the URL and opens the + /// system browser). This is the signature every production call site uses. pub fn new(cfg: PkceOAuthConfig) -> Result, AgentError> { + Self::new_with(cfg, Arc::new(DefaultBrowserOpener)) + } + + /// Construct with an injected [`BrowserOpener`]. Tests substitute a + /// recording/failing opener to exercise the browser branch without a real + /// window; Phase 2 Desktop injects the Tauri opener. + pub fn new_with( + cfg: PkceOAuthConfig, + opener: Arc, + ) -> Result, AgentError> { + Self::new_with_http_timeout(cfg, opener, HTTP_REQUEST_TIMEOUT) + } + + /// Construct with an injected opener *and* an explicit per-request HTTP + /// timeout. Only the refresh-timeout integration test passes the timeout + /// argument: it drives a hung token endpoint against a short bound so the + /// per-request timeout classification (`NetworkUnavailable`, never + /// `RefreshRejected`) is exercised in real time. A paused-clock test can't + /// do this — tokio auto-advances into the timer while the real loopback + /// discovery call is still in flight, tripping the timeout on the wrong + /// request. Every production and other-test path goes through + /// [`new`](Self::new) or [`new_with`](Self::new_with) at the default + /// [`HTTP_REQUEST_TIMEOUT`]. + pub fn new_with_http_timeout( + cfg: PkceOAuthConfig, + opener: Arc, + http_timeout: Duration, + ) -> Result, AgentError> { let cache_path = cache_path_for(&cfg)?; if let Some(parent) = cache_path.parent() { fs::create_dir_all(parent) .map_err(|e| AgentError::Llm(format!("oauth cache dir {parent:?}: {e}")))?; } + // Every OAuth HTTP call inherits this timeout so a hung provider can + // never stall the caller — nor the same-key callers waiting on the + // cross-process lock this holder owns. Construction is fallible, so a + // build failure propagates rather than silently falling back to an + // untimed client — an untimed client would restore exactly the + // unbounded-HTTP-under-lock failure the timeout exists to prevent. + let http = Client::builder() + .timeout(http_timeout) + .build() + .map_err(|e| AgentError::Llm(format!("oauth http client: {e}")))?; let initial = read_cache(&cache_path); Ok(Arc::new(Self { cfg, - http: Client::new(), + http, cache_path, + opener, state: Mutex::new(initial), })) } + /// Path of the cross-process advisory lock file guarding slow-path auth + /// for this cache key. Co-located with the cache so it shares the + /// per-key directory and `$HOME` override. + fn lock_path(&self) -> PathBuf { + append_ext(&self.cache_path, "lock") + } + + /// Path of the cooldown sidecar recording the last browser-attempt + /// failure for this cache key. + fn cooldown_path(&self) -> PathBuf { + append_ext(&self.cache_path, "cooldown") + } + + /// Path of the attempt sidecar recording the generation and outcome of the + /// last completed slow-path acquisition for this cache key. Drives the + /// cross-process single-flight of *failures* (see [`AttemptRecord`]). + fn attempt_path(&self) -> PathBuf { + append_ext(&self.cache_path, "attempt") + } + /// Discover authorization + token endpoints from the well-known URL. async fn endpoints(&self) -> Result { let v: Value = self @@ -194,236 +494,848 @@ impl PkceOAuthTokenSource { /// The cache holds both the access and refresh tokens, so the on-disk /// file is written owner-only (`0o600` on Unix) via an atomic /// inode-swapping rename — see [`write_private_cache`]. + /// + /// On non-Unix platforms the token is stored in-memory only: the + /// `write_private_cache` path creates files with default ACLs, which do + /// not enforce owner-only access. Disk persistence is intentionally + /// disabled until a Windows-specific owner-only DACL is implemented (see + /// the `create_private_temp_file` non-Unix branch). The cost is that each + /// process performs its own acquisition on non-Unix — cross-process + /// *success* handoff requires the shared on-disk cache, so processes + /// serialize through the lock but the loser repeats the flow rather than + /// reading the winner's token. Cross-process *failure* adoption still works + /// because it uses the attempt sidecar (no token bytes). Correct and + /// safe until owner-only DACL persistence exists. fn save(&self, state: &mut Option, token: CachedToken) -> Result<(), AgentError> { - let body = serde_json::to_vec_pretty(&token) - .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; - write_private_cache(&self.cache_path, &body).map_err(|e| { - AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) - })?; + self.persist(&token)?; *state = Some(token); Ok(()) } + /// Write `token` to the on-disk cache. Split out of [`save`](Self::save) so + /// the 401 neutralization path can rewrite the disk layer without clobbering + /// a distinct in-memory entry. No-op on non-Unix (see [`save`](Self::save)). + fn persist(&self, token: &CachedToken) -> Result<(), AgentError> { + #[cfg(unix)] + { + let body = serde_json::to_vec_pretty(token) + .map_err(|e| AgentError::Llm(format!("oauth cache serialize: {e}")))?; + write_private_cache(&self.cache_path, &body).map_err(|e| { + AgentError::Llm(format!("oauth cache write {:?}: {e}", self.cache_path)) + })?; + } + #[cfg(not(unix))] + { + // Disk persistence disabled on non-Unix: owner-only file + // permissions require a DACL that is not yet implemented. + let _ = token; + } + Ok(()) + } + + /// Neutralize the matching rejected credential in B's own in-memory `state` + /// only — no disk I/O. The joiner matching-failure path calls this rather + /// than `expire_rejected`: the leader already ran the durable disk + /// invalidation under the cross-process file lock, and re-running disk + /// mutations from the lockless joiner can race with a concurrent process C + /// that persisted a valid replacement under the same lock (C's rename can + /// be overwritten by B's unfenced rename). + /// + /// Contract: only the access-token identity is checked — the refresh token + /// is left intact so callers reaching the recovery disk-read path below can + /// still attempt a fresh token exchange with the un-revoked refresh secret. + /// + /// Limitation: the joiner's match arm triggers on a same-digest leader + /// error regardless of error code (see `acquire`'s `Err` match arm). A + /// pre-lock failure (e.g. `LockTimeout`) with a matching rejected digest + /// therefore also reaches this helper, even though the leader never + /// durably invalidated the disk copy. In that case B's in-memory entry is + /// neutralized and B returns the shared error; the disk copy survives + /// intact. A subsequent plain `bearer()` (`rejected = None`) can re-read + /// the disk entry. This is a known bounded limitation: in-memory + /// neutralization is applied without a guarantee that the durable copy is + /// also gone. + fn expire_rejected_memory(&self, state: &mut Option, rejected: Option<&str>) { + let Some(rej) = rejected else { return }; + if let Some(tok) = state.as_mut() { + if tok.access_token == rej { + tok.expires_at = Some(0); + } + } + } + + /// Neutralize a cached token the caller just reported 401-rejected. + /// + /// A 401 means the cached access token is dead even though its local expiry + /// clock still looks fresh. [`cached_hit`](Self::cached_hit) and + /// [`usable_from_disk`](Self::usable_from_disk) already exclude it for a + /// caller carrying `rejected`, but a *later* plain `bearer()` + /// (`rejected = None`) trusts the clock and would serve it, and a freshly + /// constructed source would restore it from disk. Force it expired in both + /// layers so [`is_expired`] excludes it for every future caller and every + /// fresh process, while the refresh token — which was *not* rejected and + /// drives this very recovery — stays intact. Each layer is neutralized only + /// when its access token byte-equals `rejected`, so a sibling's + /// concurrently-written distinct replacement is preserved. + /// + /// Disk neutralization is a bounded three-stage process: on atomic-rewrite + /// failure (e.g. non-writable parent directory), the implementation falls + /// back to an in-place truncating overwrite of the existing file (no + /// parent-dir perms required), and finally to `remove_file`. If all three + /// fail the file survives; `cached_hit`'s `rejected`-aware filter protects + /// this caller's path, but a later plain `bearer()` could re-read the + /// unexpired file. That residual corner is outside the normal threat model + /// (owner actively hardening their own cache file to 0400 against their own + /// process). + fn expire_rejected(&self, state: &mut Option, rejected: Option<&str>) { + let Some(rej) = rejected else { return }; + // Neutralize the in-memory entry: force-expire so `is_expired` excludes + // it for every subsequent in-process caller, while the refresh token + // (which was not rejected) stays intact for the recovery below. + if let Some(tok) = state.as_mut() { + if tok.access_token == rej { + tok.expires_at = Some(0); + } + } + // Neutralize the on-disk copy. Prefer atomic rewrite via `persist()` + // (temp-file + rename, owner-only permissions). If the atomic rewrite + // fails (e.g. the parent directory denies temp-file creation), fall back + // to in-place truncating overwrite: `OpenOptions::write().truncate(true)` + // on the existing file does not require parent-directory write permission, + // only that the file itself is owner-writable (0600, which our cache files + // always are). As a last resort, attempt `remove_file`. The two-stage + // fallback covers the proven hostile case: a 0600 token file under a + // 0500 parent — the atomic path cannot create the temp file (EACCES), but + // the in-place write succeeds because the file's own mode permits it. + // Residual out of threat model: if the owner explicitly chmodded their own + // cache file to 0400 before this runs, the in-place write also fails and + // we fall through to `remove_file`; if that too fails, the file survives + // with `expires_at = 0` still NOT written — `cached_hit`'s + // `rejected`-aware filter still protects the calling 401-recovery path, + // but a later plain `bearer()` could re-adopt the file. That corner is + // not in the normal threat model (a user actively hardening their own + // cache file against their own process). + if let Some(mut disk) = read_cache(&self.cache_path) { + if disk.access_token == rej { + disk.expires_at = Some(0); + if self.persist(&disk).is_err() { + // Atomic rewrite failed. Try in-place truncating overwrite — + // does not need parent-dir write permission, only the file's + // own mode. + let inplace_ok = serde_json::to_vec_pretty(&disk).ok().is_some_and(|body| { + use std::io::Write as _; + fs::OpenOptions::new() + .write(true) + .truncate(true) + .open(&self.cache_path) + .and_then(|mut f| f.write_all(&body)) + .is_ok() + }); + if !inplace_ok { + let _ = fs::remove_file(&self.cache_path); + } + } + } + } + } + /// Exchange a refresh token for a fresh access token. - async fn refresh( - &self, - endpoints: &OidcEndpoints, - refresh_token: &str, - ) -> Result { + /// + /// The outcome is typed so the caller can tell an actual credential + /// rejection apart from a transient fault. Only a token-endpoint rejection + /// of the grant itself (a 4xx `invalid_grant`-class response) is a dead + /// refresh token; a transport failure, timeout, 5xx, or an + /// undecodable/malformed response is infrastructural and must never be + /// mistaken for a credential decision (it would otherwise pop a browser or + /// return `RefreshRejected` when nothing was actually rejected). + async fn refresh(&self, endpoints: &OidcEndpoints, refresh_token: &str) -> RefreshOutcome { let params = [ ("grant_type", "refresh_token"), ("refresh_token", refresh_token), ("client_id", &self.cfg.client_id), ]; - let resp = self + let resp = match self .http .post(&endpoints.token_endpoint) .form(¶ms) .send() .await - .map_err(|e| AgentError::Llm(format!("oauth refresh: {e}")))?; - if !resp.status().is_success() { + { + Ok(resp) => resp, + // Transport error or the per-request timeout elapsed: no verdict + // from the provider, so this is infrastructural, not a rejection. + Err(e) => { + tracing::warn!(error = %e, "oauth refresh transport failure"); + return RefreshOutcome::Network; + } + }; + let status = resp.status(); + if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(AgentError::Llm(format!("oauth refresh failed: {body}"))); + // Per RFC 6749 §5.2 only `error == "invalid_grant"` means the + // refresh token itself is dead (expired/revoked) — 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 are + // infrastructural or misconfiguration: a browser can't fix them, so + // they stay in the non-credential bucket and surface as + // `NetworkUnavailable` without ever popping a browser. + if status.is_client_error() + && serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_owned)) + .as_deref() + == Some("invalid_grant") + { + tracing::warn!(status = %status, body = %body, "oauth refresh grant rejected"); + return RefreshOutcome::Rejected; + } + tracing::warn!(status = %status, body = %body, "oauth refresh not repairable by browser"); + return RefreshOutcome::Network; + } + let v: Value = match resp.json().await { + Ok(v) => v, + Err(e) => { + tracing::warn!(error = %e, "oauth refresh response decode failure"); + return RefreshOutcome::Network; + } + }; + match token_from_response(&v, Some(refresh_token)) { + Ok(token) => RefreshOutcome::Refreshed(token), + Err(e) => { + tracing::warn!(error = %e, "oauth refresh response missing access_token"); + RefreshOutcome::Network + } } - let v: Value = resp - .json() - .await - .map_err(|e| AgentError::Llm(format!("oauth refresh json: {e}")))?; - token_from_response(&v, Some(refresh_token)) } - /// Run the full browser-mediated Authorization Code + PKCE flow. - /// Caller must hold a TTY/browser: this opens a window and blocks. + /// Run the full browser-mediated Authorization Code + PKCE flow and cache + /// the result. Routes through the coordinator as a [`UserInitiated`] + /// acquisition: it may open a browser, bypasses (and clears) any cooldown, + /// and single-flights with concurrent callers on the cross-process lock. A + /// still-valid cached token short-circuits to success without re-prompting. + /// + /// This is the no-rejected convenience: it trusts the local expiry clock, + /// so a not-yet-expired cached token is accepted. When the caller already + /// knows the cached bearer was rejected by the server (a 401), it must use + /// [`acquire_with_intent`](Self::acquire_with_intent) with `rejected` set + /// so the stale-but-fresh token can't short-circuit the sign-in. + /// + /// [`UserInitiated`]: AuthIntent::UserInitiated pub async fn interactive_login(&self) -> Result<(), AgentError> { - let endpoints = self.endpoints().await?; - let token = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?; - let mut state = self.state.lock().await; - self.save(&mut state, token)?; + self.acquire(AuthIntent::UserInitiated, None).await?; Ok(()) } -} -#[async_trait] -impl TokenSource for PkceOAuthTokenSource { - async fn bearer(&self) -> Result { - let mut state = self.state.lock().await; + /// Public entry for passive Desktop discovery and the saved-model picker + /// (Phase 2): acquire a bearer under an explicit [`AuthIntent`], returning + /// the typed [`AuthError`] so the caller can branch on a stable `code` + /// rather than display text. The [`TokenSource`] trait methods wrap this + /// and flatten the error into [`AgentError`]. + /// + /// `rejected` carries the exact access token the provider just 401'd, if + /// any. With `rejected = None` a locally-fresh cached token is a hit (the + /// normal discovery path). With `rejected = Some(t)` the expiry clock is + /// untrustworthy — the rejected token looked fresh — so a cached token + /// equal to `t` is *not* a hit: the acquisition refreshes, and for `Auto` + /// or `UserInitiated` falls through to a browser when the refresh grant is + /// dead. This is what lets the saved-picker recovery path say "this + /// locally-fresh bearer was just rejected — replace it" instead of + /// re-returning the dead token, which `refresh_now`'s hardcoded + /// [`Headless`](AuthIntent::Headless) can never escalate to a browser. + pub async fn acquire_with_intent( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + self.acquire(intent, rejected).await + } - // 1. In-memory cache hit, still fresh. + /// Return a usable cached bearer, applying the identity rule for a + /// 401-driven acquisition. + /// + /// `rejected = None` (normal): a not-yet-expired cached token is a hit. + /// `rejected = Some(t)`: the expiry clock is untrustworthy — the rejected + /// token looked locally fresh — so a hit requires the cached token to + /// *differ* from `t` (a sibling already replaced it) **and** still be + /// unexpired. Without the expiry check an expired sibling token B could be + /// returned as A's replacement, skipping the refresh the 401 demanded. + /// Checks the in-memory cell first, then re-reads disk (a sibling process + /// may have written a newer token) and adopts it into the cell on a hit. + fn cached_hit( + &self, + state: &mut Option, + rejected: Option<&str>, + ) -> Option { + let usable = + |tok: &CachedToken| !is_expired(tok) && rejected != Some(tok.access_token.as_str()); if let Some(tok) = state.as_ref() { - if !is_expired(tok) { - return Ok(tok.access_token.clone()); + if usable(tok) { + return Some(tok.access_token.clone()); } } - - // 2. Re-read disk — another process may have refreshed already. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + if let Some(disk) = read_cache(&self.cache_path) { + if usable(&disk) { + let bearer = disk.access_token.clone(); + *state = Some(disk); + return Some(bearer); } } + None + } - // 3. Try refresh if we have a refresh token. Discover endpoints once - // here — deliberately hoisted above the refresh-token check so the - // browser flow at step 5 (which also needs them) reuses this call. - let endpoints = self.endpoints().await?; - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - if let Some(rt) = refresh { - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - return Ok(bearer); - } - Err(e) => { - tracing::warn!(error = %e, "oauth refresh failed; falling back to browser flow"); + /// Lock-free variant of [`cached_hit`]'s disk branch: read the on-disk + /// cache and return its bearer if a sibling wrote a usable replacement for + /// `rejected`. Used by the joiner's shared-failure recheck, where every + /// waiter wakes at once — taking `self.state` (even with `try_lock`) would + /// either drop the replacement for `try_lock` losers or serialize the read + /// behind a new leader holding `state` across its browser flow. The + /// in-memory memo is intentionally not updated; the next real acquisition + /// re-reads and adopts under the lock. + fn usable_from_disk(&self, rejected: Option<&str>) -> Option { + let disk = read_cache(&self.cache_path)?; + (!is_expired(&disk) && rejected != Some(disk.access_token.as_str())) + .then_some(disk.access_token) + } + + /// Discover OIDC endpoints once per flow, memoizing into `slot` so the + /// refresh and browser branches share a single discovery call. A discovery + /// failure (unreachable URL or malformed document) maps to + /// [`AuthError::NetworkUnavailable`] — the infrastructural bucket, so the + /// caller's retry loop treats it as transient rather than as an auth + /// decision. + async fn discover<'a>( + &self, + slot: &'a mut Option, + ) -> Result<&'a OidcEndpoints, AuthError> { + if slot.is_none() { + let eps = self + .endpoints() + .await + .map_err(|_| AuthError::NetworkUnavailable)?; + *slot = Some(eps); + } + Ok(slot.as_ref().expect("endpoints just populated")) + } + + /// The single acquisition entry point behind every [`TokenSource`] method. + /// + /// `intent` decides browser and cooldown policy; `rejected` (`Some` only on + /// a 401-driven refresh) switches cache checks from clock-based to + /// identity-based. The fast path returns a usable cached token without + /// touching the lock or the network. Otherwise the slow path serializes + /// every same-key caller — in this process *and* across processes — on the + /// cross-process advisory lock, so concurrent dialogs coalesce onto one + /// refresh/browser flow instead of racing browsers. + async fn acquire( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + // Fast path: no lock, no network. `try_lock` rather than `lock().await` + // so a caller arriving while a leader holds `state` across its browser + // flow does not block here — it falls through to the in-process + // registry below and joins the leader instead of waiting out the whole + // flow and then racing in as a second leader. A cache hit is still + // served without the file lock; a miss (or contention) coalesces. + { + if let Ok(mut state) = self.state.try_lock() { + if let Some(hit) = self.cached_hit(&mut state, rejected) { + return Ok(hit); } } + } - // 4. Re-read disk after refresh failure — another process may have won the race. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + // In-process single-flight (see [`INFLIGHT`]). Keyed by (lock path, + // intent): callers with the same intent coalesce, so a caller already + // waiting when the leader's attempt is in flight shares the leader's + // result instead of taking the lock after it and launching a second + // browser. Distinct intents key separately: a `Headless` caller never + // shares a browser-capable slot, and — critically — a `UserInitiated` + // caller never inherits an `Auto` leader's cooldown-suppressed result, + // since the two disagree on cooldown and browser policy. Those cases + // still coordinate through the cross-process file lock. + let key: InflightKey = (self.lock_path(), intent); + let (slot, is_leader) = { + let mut reg = inflight_registry(); + match reg.get(&key) { + Some(existing) => (existing.clone(), false), + None => { + let slot = Arc::new(InflightSlot::new()); + reg.insert(key.clone(), slot.clone()); + (slot, true) + } + } + }; + if !is_leader { + // Pre-existing joiner: observe the leader's outcome, but do not + // adopt a result that violates *this* caller's contract. The slot + // is keyed only by (lock path, intent), so a joiner shares a leader + // that ran with a *different* `rejected` value — and the leader's + // result can be wrong for us in two ways: + // + // * It may publish a token equal to THIS caller's `rejected` + // bytes — e.g. its cache re-read adopted a sibling write we + // just reported 401-rejected. Returning it would retry the + // provider with the exact credentials it refused. We instead + // run our own acquisition: the slot is evicted before publish + // (see [`LeaderGuard::complete`]), so this is a fresh, bounded, + // leader-eligible attempt — not a re-join of the dead + // generation, and not a loop. Its cache re-read excludes our + // `rejected`, and `finish`'s persistence-boundary guard rejects + // any refresh- or browser-issued token equal to our `rejected` + // with a typed error before caching it — so the rerun never + // hands us back our `rejected` on any path. + // + // * It may publish a terminal failure from a *rejection-relative* + // cause — e.g. refresh reissued the leader's own `rejected` bytes + // and `finish()` returned `RefreshRejected`. That failure is valid + // only for the leader's specific rejected token; a joiner with a + // *different* `rejected` (or none) should rerun: its refresh may + // yield a valid token. The leader publishes its rejected-token + // SHA-256 digest so joiners can compare without inspecting the + // token bytes directly. A digest mismatch triggers an `acquire_leader` + // rerun (the slot is already evicted). A false rerun (non-rejection + // failure with digest mismatch) costs one network round-trip and + // stays headless — far better than silently adopting a wrong denial. + // + // * It may publish a terminal failure even though a sibling wrote + // a valid replacement into the cache while we waited. We + // re-check the cache cheaply before adopting the failure — a + // lock-free disk read, never a browser or refresh — so a shared + // failure can never fan out into an N-way browser storm. The + // disk read is lock-free (`usable_from_disk`, not under `state`) + // because all waiters wake together and the in-memory memo is + // not load-bearing here — the next real acquisition re-reads and + // adopts under the lock. + let (leader_rejected_digest, outcome) = slot.wait().await; + match outcome { + Ok(token) if Some(token.access_token.as_str()) != rejected => { + // Conditionally reconcile this source's own credential + // state so a subsequent plain `bearer()` on this source + // returns the newly-acquired token rather than a stale or + // absent credential. Adopt when B's state is absent, + // expired, or still pointing at B's own rejected token. + // Preserve a distinct newer usable credential — if another + // task independently installed a valid token into B's state + // between B joining and B waking, that token is better than + // the shared result and must not be overwritten. + // + // `lock().await` rather than `try_lock`: the reconciliation + // must complete before returning. The joiner holds neither + // the INFLIGHT registry mutex nor the cross-process file + // lock at this point, so awaiting `state` cannot deadlock + // and skipping the write would leave stale or empty state, + // recreating the original P1 regression on the next plain + // `bearer()` call. + { + let mut state = self.state.lock().await; + let adopt = state.as_ref().is_none_or(|cur| { + is_expired(cur) || rejected.is_some_and(|rej| cur.access_token == rej) + }); + if adopt { + *state = Some(token.clone()); + } + } + return Ok(token.access_token); + } + Ok(_) => { + return self + .acquire_leader(intent, rejected) + .await + .map(|t| t.access_token); + } + Err(shared) => { + // Reject-digest mismatch: the leader's failure was + // rejection-relative to ITS OWN `rejected` token, not ours. + // Rerun so we can pursue our own refresh/browser path. + if leader_rejected_digest != digest_of(rejected) { + return self + .acquire_leader(intent, rejected) + .await + .map(|t| t.access_token); + } + // Neutralize B's matching rejected in-memory state so a + // subsequent plain `bearer()` on this source does not + // resurface the rejected credential. + // + // `lock().await` rather than `try_lock`: expiry must + // complete before returning. The joiner holds neither the + // INFLIGHT registry mutex nor the cross-process file lock + // here, so awaiting `state` cannot deadlock. Skipping the + // expiry would leave matching rejected X live, recreating + // the original P1 regression on the next plain `bearer()`. + // + // In-memory only (`expire_rejected_memory`, not + // `expire_rejected`): the leader already ran the durable + // disk invalidation under the file lock. Re-running disk + // writes here is lockless — process C may have persisted a + // valid replacement under the same lock between A's failure + // and this rename, and B's unfenced rename would overwrite + // it. Note: a subsequent plain `bearer()` (`rejected=None`) + // calls `cached_hit` before the cross-process lock and can + // therefore re-read the disk copy without acquiring the lock. + { + let mut state = self.state.lock().await; + self.expire_rejected_memory(&mut state, rejected); + } + if let Some(hit) = self.usable_from_disk(rejected) { + return Ok(hit); + } + return Err(shared); } } } - // 5. No usable cache: full browser dance. - let fresh = browser_pkce_flow(&self.http, &self.cfg, &endpoints).await?; - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - Ok(bearer) + // Leader: run the real flow, then evict + publish. The guard makes + // eviction and joiner wake-up happen even if this future is cancelled + // or panics, so a dropped leader can never wedge its joiners or leave a + // dead slot that turns later callers into joiners of nothing. + let guard = LeaderGuard::new(key, slot); + let result = self.acquire_leader(intent, rejected).await; + guard.complete(result, digest_of(rejected)) } - async fn bearer_no_browser(&self) -> Result { - self.try_bearer_no_browser().await + /// The leader's slow-path body: take the cross-process lock, then run the + /// bounded acquisition under it. Split out so [`acquire`] can wrap it in + /// the in-process single-flight without the lock/deadline logic bleeding + /// into the joiner path. + async fn acquire_leader( + &self, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + // Snapshot the current attempt generation *before* queueing on the + // lock. When we acquire the lock, we compare: if the generation + // advanced, a predecessor completed while we were waiting and we can + // adopt its outcome instead of re-running the full flow. + let attempt_path = self.attempt_path(); + let snapshot_gen = read_attempt(&attempt_path) + .map(|r| r.generation) + .unwrap_or(0); + // Observability hook: cross-process tests install a tracing layer that + // watches for this event to establish deterministic ordering — it fires + // after the snapshot is taken and before the process queues on the lock. + tracing::trace!( + target: "buzz_agent::auth::acquire_leader_snapshot", + snapshot_gen, + "snapshot taken" + ); + + // Slow path: one flow at a time per cache key. The waiter's deadline + // exceeds a healthy holder's attempt deadline, so it never gives up on + // a live holder. + let deadline = std::time::Instant::now() + LOCK_WAIT_TIMEOUT; + let _guard = acquire_auth_lock(&self.lock_path(), deadline).await?; + + // Bound the whole locked attempt so a wedged flow can't hold the lock + // past the waiters' patience. The deadline is passed *into* + // `acquire_locked` rather than wrapped around it in a cancelling + // `tokio::time::timeout`: a cancel drops the future at an arbitrary + // await point, which would skip the cooldown write for a timed-out + // interactive attempt and let the next `Auto` caller re-pop a browser. + // Threading the deadline lets every interactive timeout exit through + // the common outcome writer while the lock is still held. + let attempt_deadline = std::time::Instant::now() + AUTH_ATTEMPT_DEADLINE; + self.acquire_locked( + intent, + rejected, + attempt_deadline, + &attempt_path, + snapshot_gen, + ) + .await } - /// Force-refresh after a 401, never touching the browser flow. + /// Slow-path body, run while holding the cross-process auth lock. /// - /// `rejected` is the access token the server just 401'd. Coalescing keys - /// off token *identity*, not the expiry clock: a 401 means the token was - /// rejected while it still looked locally fresh, so `is_expired()` would - /// say "keep it" and no grant would ever run. Instead, under the lock we - /// compare the current cached token to `rejected` — if they differ, a - /// concurrent caller (this process or a sibling) already refreshed, so we - /// return the new token without burning a second grant. If they still - /// match, this is the rejected token and we run the refresh-token grant - /// unconditionally. The whole check→refresh→save runs under one lock hold - /// so concurrent callers serialize. On any failure the refresh token is - /// preserved (never nulled) and the error is terminal `LlmAuth` — no - /// browser, no hang. - async fn refresh_now(&self, rejected: &str) -> Result { + /// `attempt_deadline` bounds the whole locked flow. Discovery and refresh + /// are each bounded by the HTTP client's per-request timeout; the browser + /// flow is wrapped in the *remaining* budget so a total-deadline expiry + /// during the interactive step surfaces as [`AuthError::TimedOut`] through + /// the same arm that records the cooldown — never as a cancellation that + /// drops the guard without writing it. + /// + /// `attempt_path` + `snapshot_gen` implement cross-process failure + /// single-flight: the caller snapshotted `snapshot_gen` before queueing on + /// the lock; if the generation has since advanced, a predecessor completed + /// while we waited. A caller already queued when the predecessor ran adopts + /// its same-intent terminal failure rather than re-running — including + /// `UserInitiated` callers, mirroring what [`INFLIGHT`] does within one + /// process. A `UserInitiated` caller arriving *after* the failure snapshots + /// the new generation and naturally does not adopt. + async fn acquire_locked( + &self, + intent: AuthIntent, + rejected: Option<&str>, + attempt_deadline: std::time::Instant, + attempt_path: &Path, + snapshot_gen: u64, + ) -> Result { let mut state = self.state.lock().await; - // 1. Coalesce by identity: if the cached token (in-memory, then disk) - // is no longer the one the server rejected, someone already - // refreshed it. Return that instead of grabbing another grant. - if let Some(tok) = state.as_ref() { - if tok.access_token != rejected { - return Ok(tok.access_token.clone()); - } + // A 401 (`rejected = Some`) proves the cached access token is dead even + // though its local expiry clock still looks fresh. Neutralize it now, + // under the lock, so it can never be served again: cache_hit already + // excludes it for callers carrying `rejected`, but a later plain + // `bearer()` (`rejected = None`) or a freshly constructed source would + // otherwise trust the clock and hand back the proven-dead bytes. The + // refresh token is untouched — it was not rejected and drives the + // recovery below. + self.expire_rejected(&mut state, rejected); + + // Re-check under the lock: a holder we queued behind may have already + // produced a token (this process or a sibling wrote the cache). + if self.cached_hit(&mut state, rejected).is_some() { + // `cached_hit` guarantees state is populated on a hit (memory entry + // was already there, or disk token was adopted into state). + return Ok(state.clone().expect("cached_hit confirmed token in state")); } - if let Some(disk_tok) = read_cache(&self.cache_path) { - if disk_tok.access_token != rejected { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + + // Cross-process failure single-flight. A predecessor completed while + // this caller was waiting on the lock: check whether its outcome was a + // terminal failure we should adopt rather than re-run. The contract is + // *temporal*, not intent-based: a caller whose pre-queue snapshot is + // older than the current generation was already queued while the + // predecessor ran and may adopt its failure, mirroring how the + // in-process [`INFLIGHT`] registry coalesces same-intent callers + // (including `UserInitiated`) within a single process. A `UserInitiated` + // caller arriving *after* a failure naturally snapshots the new + // generation and does not adopt, so "later explicit user retry bypasses" + // falls out without a special case. The conditions are: + // (a) the attempt generation advanced past our snapshot — we were + // queued while the predecessor ran, not a fresh arrival after it; + // (b) the recorded intent matches ours — cross-process adoption + // respects the same (path, intent) boundary as INFLIGHT, so a + // `UserInitiated` waiter never inherits an `Auto`/`Headless` + // failure (different intent, different promise to the user); + // (c) the recorded result is a recognized terminal failure — `"ok"` + // and unrecognized codes fall through to a normal attempt; + // (d) the recorded rejected_digest matches ours — a failure caused by + // the predecessor's specific rejected token is not valid for a + // caller with a *different* rejected token (both-`None` matches). + // A digest mismatch triggers a normal attempt; a false rerun on a + // non-rejection-relative failure costs one network round-trip and + // stays headless — preferable to silently serving a wrong denial. + // + // Adoptors do NOT write a new attempt record: adopting does not + // represent new work. Writing one would advance the generation so a + // third caller that arrives after the adoption (snapshot = new gen) sees + // no advance and tries its own attempt — but a fourth arriving while the + // third runs would inherit the adopter's re-written record, relaying the + // original failure indefinitely. The original record already has the + // correct generation; subsequent waiters with snapshot < original gen + // still adopt from it directly. + if let Some(rec) = read_attempt(attempt_path) { + if rec.generation > snapshot_gen + && rec.intent == intent.as_str() + && rec.rejected_digest == digest_of(rejected) + { + if let Some(err) = AuthError::from_code(&rec.result) { + return Err(err); + } } } - // 2. The cached token is still the rejected one. Run the refresh-token - // grant unconditionally — the expiry clock can't be trusted here, a - // locally-fresh token is exactly what got 401'd. - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - let Some(rt) = refresh else { - return Err(AgentError::LlmAuth( - "token rejected and no refresh token available".into(), - )); - }; - let endpoints = self.endpoints().await?; - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - Ok(bearer) + // Refresh-token grant, if we have one. Endpoints are discovered lazily + // here (and reused by the browser branch) so a no-refresh headless + // failure never depends on reaching the discovery URL. + let mut endpoints: Option = None; + let mut refresh_failed = false; + if let Some(rt) = state.as_ref().and_then(|t| t.refresh_token.clone()) { + let eps = self.discover(&mut endpoints).await?; + match self.refresh(eps, &rt).await { + RefreshOutcome::Refreshed(fresh) => { + let result = self.finish(&mut state, fresh, intent, rejected); + // Record recognized terminal failures (rejected-equal reissuance) + // so a cross-process headless waiter can adopt them rather than + // re-running the same dead refresh. Successes are shared through + // the token cache — a waiter that wins the lock after us finds + // the token via `cached_hit` without reaching the adoption check. + if let Err(ref e) = result { + write_attempt(attempt_path, intent, e.code(), rejected); + } + return result; + } + // A transient fault (transport/timeout/5xx/decode) is not a + // credential decision: never fall through to a browser or + // report RefreshRejected. A sibling may have written a fresh + // token while we ran, so honor that first; otherwise this is + // infrastructural and surfaces as NetworkUnavailable. + RefreshOutcome::Network => { + if self.cached_hit(&mut state, rejected).is_some() { + return Ok(state.clone().expect("cached_hit confirmed token in state")); + } + return Err(AuthError::NetworkUnavailable); + } + // The token endpoint rejected the grant: a dead refresh token. + // A sibling may still have won the race while we ran; if not, + // fall through to a browser (interactive) or RefreshRejected + // (headless). + RefreshOutcome::Rejected => { + if self.cached_hit(&mut state, rejected).is_some() { + return Ok(state.clone().expect("cached_hit confirmed token in state")); + } + refresh_failed = true; + } } - // 3. Refresh token is itself dead. Terminal — surfacing LlmAuth - // stops the retry loop instead of falling to the browser flow, - // which would hang a headless harness. - Err(e) => Err(AgentError::LlmAuth(format!("token refresh failed: {e}"))), } - } -} - -impl PkceOAuthTokenSource { - /// Return a bearer token from cache or refresh, **never** opening a browser. - /// - /// Follows the same steps as [`bearer`](TokenSource::bearer) but stops at - /// step 4 — if no usable token is available after cache + refresh attempts, - /// returns `Err(LlmAuth(...))` instead of launching the browser PKCE flow. - /// Used by model-discovery paths that must not block on user interaction. - pub(crate) async fn try_bearer_no_browser(&self) -> Result { - let mut state = self.state.lock().await; - // 1. In-memory cache hit, still fresh. - if let Some(tok) = state.as_ref() { - if !is_expired(tok) { - return Ok(tok.access_token.clone()); - } + // No token from cache or refresh. Browser or terminal failure. + if !intent.may_open_browser() { + let err = if refresh_failed { + AuthError::RefreshRejected + } else { + AuthError::NoCredential + }; + write_attempt(attempt_path, intent, err.code(), rejected); + return Err(err); } - // 2. Re-read disk — another process may have refreshed already. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + let cooldown_path = self.cooldown_path(); + if intent.honors_cooldown() { + // A recent browser attempt failed; surface its recorded outcome + // instead of re-popping a browser on this automatic attempt. + if let Some(recorded) = read_cooldown(&cooldown_path) { + return Err(recorded); } + } else { + // An explicit user retry clears any prior suppression. + clear_cooldown(&cooldown_path); } - // 3. Try refresh if we have a refresh token. Endpoints are discovered - // lazily here — only when a refresh token is actually present — so - // that an unreachable OIDC discovery URL cannot prevent the - // no-token/no-cache path from returning `LlmAuth` (graceful - // fallback) instead of `Llm` (hard error). - let refresh = state.as_ref().and_then(|t| t.refresh_token.clone()); - if let Some(rt) = refresh { - let endpoints = self.endpoints().await?; - match self.refresh(&endpoints, &rt).await { - Ok(fresh) => { - let bearer = fresh.access_token.clone(); - self.save(&mut state, fresh)?; - return Ok(bearer); - } - Err(e) => { - tracing::warn!(error = %e, "oauth refresh failed during model discovery"); - } + let eps = self.discover(&mut endpoints).await?; + // Wrap the browser flow in the *remaining* attempt budget so the total + // locked time never exceeds `attempt_deadline` (and thus never + // outlasts a waiter's `LOCK_WAIT_TIMEOUT`). A deadline expiry maps to + // `TimedOut`, which is cooldown-worthy, so it flows through the same + // writer arm below instead of being dropped by a cancel that would + // release the lock without recording the cooldown. + let remaining = attempt_deadline.saturating_duration_since(std::time::Instant::now()); + let flow = browser_pkce_flow(&self.http, &self.cfg, eps, self.opener.as_ref()); + let outcome = match tokio::time::timeout(remaining, flow).await { + Ok(result) => result, + Err(_) => Err(AuthError::TimedOut), + }; + match outcome { + // `finish` clears the cooldown on success and rejects a re-issued + // 401'd token before persisting it. + Ok(fresh) => { + let result = self.finish(&mut state, fresh, intent, rejected); + let code = match &result { + Ok(_) => "ok", + Err(e) => e.code(), + }; + write_attempt(attempt_path, intent, code, rejected); + result } - - // 4. Re-read disk after refresh failure. - if let Some(disk_tok) = read_cache(&self.cache_path) { - if !is_expired(&disk_tok) { - let bearer = disk_tok.access_token.clone(); - *state = Some(disk_tok); - return Ok(bearer); + Err(e) => { + if e.is_cooldown_worthy() { + write_cooldown(&cooldown_path, &e); } + write_attempt(attempt_path, intent, e.code(), rejected); + Err(e) } } + } + + /// Persist a freshly-obtained token, clear any cooldown, and return the + /// full [`CachedToken`] on success. A cache-write failure maps to + /// [`AuthError::NetworkUnavailable`] (the infrastructural bucket) — the + /// token was valid but couldn't be persisted, which the caller should treat + /// as transient, not as a credential rejection. + /// + /// The candidate-token persistence boundary for refresh and browser results. + /// Cache-hit paths bypass this function, but every refresh- or browser-issued + /// token flows through here before being written to memory or disk. This is + /// where the 401-recovery invariant is enforced: a token equal to the + /// caller's `rejected` bytes must never be committed — doing so would cache + /// the proven-dead token as fresh, so a later plain `bearer()` (`rejected = + /// None`) or a freshly constructed source reading the same cache would serve + /// it back. Validating *before* the write keeps the dead token out of the + /// cache and off disk entirely: we fail typed (`NetworkUnavailable` interactive + /// / `RefreshRejected` headless) without caching it or clearing the cooldown. + /// `cached_hit` and `usable_from_disk` already exclude `rejected`, so guarding + /// the two live-token sites (refresh and browser exchange) here covers every + /// path that can produce the rejected bytes. + /// + /// Returning the full [`CachedToken`] (rather than just the bearer string) + /// lets `acquire_locked` → `acquire_leader` propagate it all the way to + /// [`LeaderGuard::complete`], which publishes it through the [`InflightSlot`] + /// so every joiner can reconcile its own independent `state` cell. + fn finish( + &self, + state: &mut Option, + token: CachedToken, + intent: AuthIntent, + rejected: Option<&str>, + ) -> Result { + if rejected == Some(token.access_token.as_str()) { + return Err(if intent.may_open_browser() { + AuthError::NetworkUnavailable + } else { + AuthError::RefreshRejected + }); + } + self.save(state, token.clone()) + .map_err(|_| AuthError::NetworkUnavailable)?; + clear_cooldown(&self.cooldown_path()); + Ok(token) + } +} + +#[async_trait] +impl TokenSource for PkceOAuthTokenSource { + /// Acquire a bearer for a request. Routes through the coordinator as a + /// [`Headless`](AuthIntent::Headless) acquisition: it serves a cached or + /// refreshed token but never opens a browser, so a managed runtime with no + /// interactive display can never hang on inference. First-use auth is the + /// job of `buzz-agent auth databricks` ([`interactive_login`]). + /// + /// [`interactive_login`]: PkceOAuthTokenSource::interactive_login + async fn bearer(&self) -> Result { + self.acquire(AuthIntent::Headless, None) + .await + .map_err(Into::into) + } + + /// Identical to [`bearer`](Self::bearer) for this source — both are + /// headless. Retained as a distinct method so callers can state the + /// no-browser requirement at the call site (and so other [`TokenSource`] + /// impls that *would* browse in `bearer` can still expose a safe path). + async fn bearer_no_browser(&self) -> Result { + self.acquire(AuthIntent::Headless, None) + .await + .map_err(Into::into) + } - // No usable token — return error instead of opening a browser. - Err(AgentError::LlmAuth( - "no cached Databricks token; run `buzz-agent auth databricks` first".into(), - )) + /// Force a fresh bearer after the server rejected `rejected` with a 401. + /// + /// A [`Headless`](AuthIntent::Headless) acquisition keyed by token + /// *identity* rather than the expiry clock: a 401 means the cached token + /// was rejected while still locally fresh, so [`is_expired`] would wrongly + /// keep it. Passing `rejected` makes the coordinator run the refresh-token + /// grant unless a concurrent caller already replaced the token, in which + /// case that newer token is returned without a second grant. Never opens a + /// browser; a dead refresh token surfaces terminally so the retry loop + /// stops instead of hanging. + async fn refresh_now(&self, rejected: &str) -> Result { + self.acquire(AuthIntent::Headless, Some(rejected)) + .await + .map_err(Into::into) } } // ---- helpers ------------------------------------------------------------- +/// SHA-256 hex digest of `rejected` token bytes, or `None` when there is no +/// rejected token. Used to scope in-process and cross-process failure adoption +/// to the specific token that was rejected — a joiner carrying a *different* +/// rejected token (or none) must not inherit a rejection-relative failure. +fn digest_of(rejected: Option<&str>) -> Option { + rejected.map(|r| hex::encode(sha2::Sha256::digest(r.as_bytes()))) +} + /// Aborts a spawned task when dropped. Used to guarantee the localhost /// callback server doesn't outlive a failed/abandoned PKCE attempt. struct AbortOnDrop(tokio::task::JoinHandle<()>); @@ -438,11 +1350,7 @@ fn is_expired(t: &CachedToken) -> bool { let Some(exp) = t.expires_at else { return false; }; - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - now + TOKEN_REFRESH_LEEWAY.as_secs() >= exp + now_secs() + TOKEN_REFRESH_LEEWAY.as_secs() >= exp } const BUZZ_AGENT_CONFIG_DIR_ENV: &str = "BUZZ_AGENT_CONFIG_DIR"; @@ -484,6 +1392,379 @@ fn cache_path_for(cfg: &PkceOAuthConfig) -> Result { Ok(dir.join(format!("{hash}.json"))) } +/// Append `ext` as an extra extension onto `base` (e.g. `.json` → +/// `.json.lock`). Keeps the lock and cooldown sidecars in the same +/// per-key directory as the cache, so they inherit its `$HOME` override and +/// owner-only parent without a second key derivation. +fn append_ext(base: &Path, ext: &str) -> PathBuf { + let mut name = base.as_os_str().to_owned(); + name.push("."); + name.push(ext); + PathBuf::from(name) +} + +/// Durable record of the last browser-attempt failure for a cache key. Written +/// while holding the auth lock so concurrent writers can't interleave, read by +/// `Auto` callers to decide whether to suppress an automatic browser re-launch. +#[derive(Debug, Serialize, Deserialize)] +struct CooldownRecord { + /// [`AuthError::code`] of the failure being cooled down. + code: String, + /// Unix seconds after which the cooldown lapses and an `Auto` caller may + /// launch a browser again. + until: u64, +} + +/// Durable record of the generation and outcome of the most recently completed +/// slow-path acquisition attempt for a cache key. +/// +/// Cross-process single-flight for *failures*: the in-process [`INFLIGHT`] +/// registry coalesces same-key callers within one process, but two separate +/// processes both waiting on the OS file lock do NOT share the registry. When +/// process A holds the lock and fails (e.g. browser denial or dead refresh), +/// process B's queued caller acquires the lock after A releases it and — under +/// the old protocol — would re-run the full flow from scratch. This record lets +/// B detect that it was already queued while A ran and adopt A's failure +/// instead of hammering the provider again. +/// +/// Protocol: +/// - A caller **snapshots** the current generation from the sidecar *before* +/// queueing on the file lock. +/// - A caller that **acquires** the lock compares the current generation to its +/// snapshot: if it advanced, a predecessor completed while it was waiting. +/// If the recorded intent matches this caller's intent and the outcome is a +/// recognized terminal failure, adopt it rather than re-running. +/// - Completing attempts **write** a fresh record under the lock. Write +/// coverage: the headless no-browser arm (`RefreshRejected`/`NoCredential`), +/// the refresh arm when `finish()` fails typed (rejected-equal reissuance), +/// and the browser arm (all outcomes including `"ok"`). Omissions that are +/// intentionally not adoption-worthy: transient `Network` errors, discovery +/// failures (both non-terminal; next caller retries), and cache/refresh- +/// success paths (a waiting caller finds the token via `cached_hit` without +/// reaching the adoption check). +/// +/// The generation counter is read fresh from disk at write time so each +/// completed attempt strictly advances the value regardless of when the +/// caller's pre-queue snapshot was taken. +/// +/// Intent matching is same-intent only, mirroring the in-process `(path, +/// intent)` key. The temporal condition handles "later explicit retry bypasses": +/// a `UserInitiated` caller arriving after the failure snapshots the new +/// generation and sees no advance, so it always runs its own attempt and never +/// inherits a prior failure — regardless of intent. +#[derive(Debug, Serialize, Deserialize)] +struct AttemptRecord { + /// Strictly increasing counter: read from disk at write time and incremented + /// by one so each attempt advances from the actual current value regardless + /// of when the writing caller's snapshot was taken. + generation: u64, + /// Intent of the attempt that completed, as [`AuthIntent::as_str`]. + intent: String, + /// Error code of the terminal failure, or `"ok"` on success. Matches + /// [`AuthError::code`] / the `"ok"` sentinel. + result: String, + /// SHA-256 hex digest of the token bytes that the completing caller had + /// marked as `rejected`, or `None` when the caller carried no rejected + /// token. A waiter adopts only when its own digest matches: a failure caused + /// by the leader's specific rejected token is not valid for a waiter with a + /// *different* rejected token (or none) — its refresh may yield a live + /// token. Both-`None` is a match. A mismatched digest triggers a normal + /// attempt; a false rerun on a non-rejection failure costs one network round- + /// trip and stays headless — preferable to silently adopting a wrong denial. + #[serde(default)] + rejected_digest: Option, +} + +/// Read the attempt sidecar at `path`, if any. Returns `None` when absent, +/// unparseable, or the generation is 0 (no attempt has completed yet). +fn read_attempt(path: &Path) -> Option { + let body = fs::read(path).ok()?; + let record: AttemptRecord = serde_json::from_slice(&body).ok()?; + Some(record) +} + +/// Write a fresh attempt record at `path`. Called under the auth lock. +/// Best-effort — a write failure only means the next cross-process waiter +/// cannot adopt this attempt's outcome, so errors are swallowed. +/// +/// Always reads the current on-disk generation before writing so the new +/// record strictly advances from the actual last-recorded value, not from +/// any caller's pre-queue snapshot. An intervening different-intent attempt +/// that advanced the sidecar between snapshot and lock-acquire is reflected +/// correctly: the next waiter's comparison still sees a real advance. +fn write_attempt(path: &Path, intent: AuthIntent, result: &str, rejected: Option<&str>) { + let current_gen = read_attempt(path).map_or(0, |r| r.generation); + let record = AttemptRecord { + generation: current_gen.wrapping_add(1), + intent: intent.as_str().to_owned(), + result: result.to_owned(), + rejected_digest: digest_of(rejected), + }; + if let Ok(body) = serde_json::to_vec(&record) { + let _ = write_private_cache(path, &body); + } +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Return the still-active cooldown outcome for `path`, if any. +/// +/// `None` when the sidecar is absent, unparseable, expired, or records a code +/// this build doesn't recognize — every one of those means "no active +/// cooldown", so the caller proceeds to a normal attempt. An expired record is +/// removed opportunistically so the directory doesn't accumulate stale files. +fn read_cooldown(path: &Path) -> Option { + let body = fs::read(path).ok()?; + let record: CooldownRecord = serde_json::from_slice(&body).ok()?; + if record.until > now_secs() { + AuthError::from_code(&record.code) + } else { + let _ = fs::remove_file(path); + None + } +} + +/// Record `err` as a fresh cooldown at `path`, expiring [`COOLDOWN_DURATION`] +/// from now. Best-effort: a write failure only means the next automatic +/// attempt may re-pop a browser, never a hard auth failure, so errors are +/// swallowed. Called while holding the auth lock. +fn write_cooldown(path: &Path, err: &AuthError) { + let record = CooldownRecord { + code: err.code().to_string(), + until: now_secs() + COOLDOWN_DURATION.as_secs(), + }; + if let Ok(body) = serde_json::to_vec(&record) { + let _ = write_private_cache(path, &body); + } +} + +/// Remove any cooldown sidecar at `path`. Called on a successful acquisition +/// (the problem is resolved) and by `UserInitiated` callers that bypass the +/// cooldown (an explicit retry clears the suppression). Best-effort. +fn clear_cooldown(path: &Path) { + let _ = fs::remove_file(path); +} + +/// Hold on the cross-process auth lock. Dropping it (or the owning process +/// dying) releases the OS advisory lock — no PID files, no manual break. +#[derive(Debug)] +struct AuthLockGuard(fs::File); + +impl Drop for AuthLockGuard { + fn drop(&mut self) { + // Explicit for intent; closing the fd would release it regardless. + let _ = FileExt::unlock(&self.0); + } +} + +/// Acquire the cross-process auth lock at `path`, polling until `deadline`. +/// +/// `fs2::FileExt::try_lock_exclusive` maps to `flock(LOCK_EX | LOCK_NB)` on +/// Unix and `LockFileEx` on Windows — advisory, per–open-file-description, so +/// a lock taken on one handle blocks every other handle (same process or not), +/// which is exactly the cross-process single-flight guarantee we want. The +/// try-lock is non-blocking, so we poll on [`LOCK_POLL_INTERVAL`] rather than +/// parking a worker thread in a blocking `lock_exclusive()`. Contention is +/// reported as [`fs2::lock_contended_error`] (`EWOULDBLOCK`/`EACCES` on Unix, +/// `ERROR_LOCK_VIOLATION` on Windows); we match its `raw_os_error` and retry. +/// Any other error is a real fault and returns [`AuthError::LockTimeout`]. A +/// waiter whose `deadline` lapses also returns [`AuthError::LockTimeout`]; +/// because the caller sets that deadline longer than [`AUTH_ATTEMPT_DEADLINE`], +/// a healthy holder always finishes first. +async fn acquire_auth_lock( + path: &Path, + deadline: std::time::Instant, +) -> Result { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|_| AuthError::LockTimeout)?; + } + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(path) + .map_err(|_| AuthError::LockTimeout)?; + let contended = fs2::lock_contended_error().raw_os_error(); + loop { + match file.try_lock_exclusive() { + Ok(()) => return Ok(AuthLockGuard(file)), + Err(e) if e.raw_os_error() == contended => { + if std::time::Instant::now() >= deadline { + return Err(AuthError::LockTimeout); + } + tokio::time::sleep(LOCK_POLL_INTERVAL).await; + } + Err(_) => return Err(AuthError::LockTimeout), + } + } +} + +/// Key for the in-process single-flight registry: the cross-process lock path +/// (one per cache key) paired with the caller's [`AuthIntent`]. Keying by the +/// full intent — not merely browser capability — keeps callers with *different* +/// outcome policy from coalescing: an `Auto` leader honors a live cooldown and +/// returns its recorded `Denied`/`TimedOut`, but a `UserInitiated` caller is +/// promised a cooldown bypass and a fresh browser, so it must never inherit an +/// `Auto` leader's suppressed result. Each intent still coalesces with itself +/// (two concurrent `UserInitiated` sign-ins share one browser), and all intents +/// on the same key still serialize through the cross-process file lock. +type InflightKey = (PathBuf, AuthIntent); + +/// Process-global registry of in-flight auth attempts, the in-process +/// counterpart to [`acquire_auth_lock`]'s cross-process file lock. The file +/// lock serializes work across processes and shares *success* via a cache +/// re-read, but a queued caller that acquires the lock after a browser denial +/// would clear the sidecar and pop a second browser. This registry closes that +/// gap: a caller that arrives while a leader's attempt is in flight joins the +/// leader's [`InflightSlot`] and receives the *same* result — success or +/// failure — instead of taking the lock afterward and launching again. Guarded +/// by a `std::sync::Mutex` because every critical section is a cheap map lookup +/// with no `.await` held. +static INFLIGHT: LazyLock>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +/// Lock the in-flight registry, recovering from a poisoned mutex rather than +/// panicking: the only work done under this lock is map lookups that can't +/// leave inconsistent state, so a poison from an unrelated panic must not wedge +/// every future auth attempt. +fn inflight_registry() -> std::sync::MutexGuard<'static, HashMap>> { + INFLIGHT.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// The value published by a leader to its joiners: the leader's rejected-token +/// SHA-256 digest (non-secret identity, `None` when the leader carried no +/// `rejected`) paired with the attempt result. Joiners use the digest to detect +/// a mismatch — the leader's rejection-relative failure is not valid for a +/// joiner that carried a *different* rejected token. +/// +/// On success the full [`CachedToken`] is published so each joiner can +/// conditionally reconcile its own independent [`PkceOAuthTokenSource::state`] +/// cell. Publishing the full credential (not just the bearer string) prevents +/// a joining source's state from remaining stale or empty after the coalesced +/// flow, which would otherwise cause a subsequent plain `bearer()` on that +/// source to resurface a rejected or absent credential rather than the +/// newly-acquired one. +type SlotPublish = (Option, Result); + +/// The shared result of one leader's auth attempt, awaited by any joiner that +/// arrived while the leader was in flight. A `watch` channel gives us +/// publish-once plus wait-for-publish in one primitive: the leader publishes +/// exactly once through [`LeaderGuard`]; joiners clone the published result. +struct InflightSlot { + tx: watch::Sender>, + rx: watch::Receiver>, +} + +impl InflightSlot { + fn new() -> Self { + let (tx, rx) = watch::channel(None); + Self { tx, rx } + } + + /// Block until the leader publishes, then clone out `(rejected_digest, result)`. + /// + /// `borrow_and_update` marks the current value seen before awaiting, so a + /// publish that lands between the read and the `changed()` await is not a + /// lost wakeup — the version has advanced, so `changed()` returns at once. + /// A closed channel (leader dropped without publishing — which + /// [`LeaderGuard`]'s `Drop` prevents) surfaces as a transient so the caller + /// retries rather than hangs. + async fn wait(&self) -> SlotPublish { + let mut rx = self.rx.clone(); + loop { + if let Some(publish) = rx.borrow_and_update().clone() { + return publish; + } + if rx.changed().await.is_err() { + return (None, Err(AuthError::NetworkUnavailable)); + } + } + } + + /// Publish `(rejected_digest, result)` to every waiting joiner. A send + /// error means no joiners remain, which is fine. + fn publish(&self, rejected_digest: Option, result: Result) { + let _ = self.tx.send(Some((rejected_digest, result))); + } +} + +/// RAII owner of a leader's in-flight slot. Guarantees the slot is evicted from +/// [`INFLIGHT`] and a result published to joiners even if the leader future is +/// cancelled or panics: a leader that skipped this would leave a dead slot that +/// turns every later caller into a joiner of an attempt that never publishes, +/// wedging them until `LOCK_WAIT_TIMEOUT`. +struct LeaderGuard { + key: InflightKey, + slot: Arc, + done: bool, +} + +impl LeaderGuard { + fn new(key: InflightKey, slot: Arc) -> Self { + Self { + key, + slot, + done: false, + } + } + + /// Normal completion: evict the slot, publish `(rejected_digest, result)` + /// to joiners, and return the bearer to the leader. The full + /// [`CachedToken`] is published so joiners can reconcile their own + /// [`PkceOAuthTokenSource::state`] before returning. Evicting *before* + /// publishing means a caller arriving after this point starts a fresh + /// attempt (a later explicit retry may launch), while joiners already + /// holding the slot still receive the result. `Drop` covers the cancel/panic + /// path. + fn complete( + mut self, + result: Result, + rejected_digest: Option, + ) -> Result { + self.done = true; + Self::evict(&self.key, &self.slot); + // Clone the error before moving `result` into the slot publish so we + // can return the original error to the leader on failure. + let leader_return = result + .as_ref() + .map(|t| t.access_token.clone()) + .map_err(|e| e.clone()); + self.slot.publish(rejected_digest, result); + leader_return + } + + /// Remove this leader's slot from the registry, but only if it is still the + /// same slot — defends against evicting a successor a later attempt may + /// have installed under the same key. + fn evict(key: &InflightKey, slot: &Arc) { + let mut reg = inflight_registry(); + if reg + .get(key) + .is_some_and(|existing| Arc::ptr_eq(existing, slot)) + { + reg.remove(key); + } + } +} + +impl Drop for LeaderGuard { + fn drop(&mut self) { + if self.done { + return; + } + // Cancelled or panicked before `complete`: evict so later callers start + // fresh, and wake joiners with a transient error so they retry rather + // than hang on a leader that will never publish. + Self::evict(&self.key, &self.slot); + self.slot.publish(None, Err(AuthError::NetworkUnavailable)); + } +} + /// Load a cached token, enforcing the owner-only invariant on load. /// /// Owner-only permissions are a cache *lifecycle* invariant, not just a @@ -536,11 +1817,23 @@ fn read_private_cache(path: &Path) -> io::Result> { Ok(body) } -/// Non-Unix fallback: read the cache as-is. Owner-only enforcement is the -/// Windows DACL work deferred behind the [`create_private_temp_file`] seam. +/// Non-Unix: token persistence and reading are both disabled until a +/// Windows-specific owner-only DACL is implemented. Any legacy token file +/// left by an older build (written with default ACLs) is deleted +/// opportunistically so the exposed artifact cannot be served by new builds. +/// Returns an error so [`read_cache`] yields `None`, giving a consistent +/// memory-only cache on non-Unix. #[cfg(not(unix))] fn read_private_cache(path: &Path) -> io::Result> { - fs::read(path) + // Best-effort removal of any legacy file. Errors are ignored — either the + // file does not exist (normal case) or it cannot be removed (no worse + // than before — the DACL story is still broken, but that is the pre-fix + // state we are trying to retire). + let _ = fs::remove_file(path); + Err(io::Error::new( + io::ErrorKind::Unsupported, + "token disk cache disabled on non-Unix (no owner-only DACL)", + )) } /// Removes a temp file on drop unless it was already renamed away. Keeps a @@ -731,21 +2024,38 @@ fn sanitize_callback_detail(raw: &str) -> String { .collect() } -/// Spin up a localhost callback server, open the authorize URL in a -/// browser, wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then -/// exchange the code for a token. +/// Spin up a localhost callback server, hand the authorize URL to `opener`, +/// wait up to [`BROWSER_AUTH_TIMEOUT`] for the redirect, then exchange the +/// code for a token. +/// +/// `opener` is invoked *after* the listener is bound and the abort guard is +/// armed, so a launch failure never returns a URL pointing at a torn-down +/// listener. Every failure is a typed [`AuthError`] so the coordinator can +/// record a cooldown (or not) by category: an open failure is +/// [`BrowserOpenFailed`], a redirect that never arrives is [`TimedOut`], a +/// provider-reported denial is [`Denied`], and a code exchange the provider +/// rejects with `invalid_grant` is [`ExchangeFailed`]; infrastructure faults +/// (bind/exchange transport, 429, 5xx, or a malformed success body) are +/// [`NetworkUnavailable`]. +/// +/// [`BrowserOpenFailed`]: AuthError::BrowserOpenFailed +/// [`TimedOut`]: AuthError::TimedOut +/// [`Denied`]: AuthError::Denied +/// [`ExchangeFailed`]: AuthError::ExchangeFailed +/// [`NetworkUnavailable`]: AuthError::NetworkUnavailable async fn browser_pkce_flow( http: &Client, cfg: &PkceOAuthConfig, endpoints: &OidcEndpoints, -) -> Result { + opener: &dyn BrowserOpener, +) -> Result { use axum::{extract::Query, response::Html, routing::get, Router}; use std::collections::HashMap; use std::net::SocketAddr; use tokio::sync::oneshot; - let (verifier, challenge) = pkce_pair()?; - let state = random_state()?; + let (verifier, challenge) = pkce_pair().map_err(|_| AuthError::NetworkUnavailable)?; + let state = random_state().map_err(|_| AuthError::NetworkUnavailable)?; let (tx, rx) = oneshot::channel::>(); let tx = Arc::new(Mutex::new(Some(tx))); @@ -768,10 +2078,10 @@ async fn browser_pkce_flow( let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) .await - .map_err(|e| AgentError::Llm(format!("oauth callback bind: {e}")))?; + .map_err(|_| AuthError::NetworkUnavailable)?; let port = listener .local_addr() - .map_err(|e| AgentError::Llm(format!("oauth callback addr: {e}")))? + .map_err(|_| AuthError::NetworkUnavailable)? .port(); let redirect_uri = format!("http://localhost:{port}"); @@ -793,14 +2103,25 @@ async fn browser_pkce_flow( urlencoding::encode(&challenge), ); - eprintln!("Opening browser for authentication. If it doesn't open, visit:\n {auth_url}"); - let _ = webbrowser::open(&auth_url); + // Launch the browser while the listener is live. A launch failure aborts + // before we wait on a redirect nobody can send. + opener.open(&auth_url).map_err(|e| { + tracing::warn!(error = %e, "oauth browser launch failed"); + AuthError::BrowserOpenFailed + })?; - let code = tokio::time::timeout(BROWSER_AUTH_TIMEOUT, rx) - .await - .map_err(|_| AgentError::Llm("oauth: browser auth timed out".into()))? - .map_err(|_| AgentError::Llm("oauth: callback sender dropped".into()))? - .map_err(|e| AgentError::Llm(format!("oauth callback: {e}")))?; + let code = match tokio::time::timeout(BROWSER_AUTH_TIMEOUT, rx).await { + // Timed out waiting for the redirect. + Err(_) => return Err(AuthError::TimedOut), + // Callback task dropped the sender without sending — treat as timeout. + Ok(Err(_)) => return Err(AuthError::TimedOut), + // Provider/user reported an error (denial, state mismatch, missing code). + Ok(Ok(Err(detail))) => { + tracing::warn!(detail = %detail, "oauth callback reported failure"); + return Err(AuthError::Denied); + } + Ok(Ok(Ok(code))) => code, + }; // Exchange code for token. let params = [ @@ -815,21 +2136,47 @@ async fn browser_pkce_flow( .form(¶ms) .send() .await - .map_err(|e| AgentError::Llm(format!("oauth exchange: {e}")))?; - if !resp.status().is_success() { + // Transport error or the per-request timeout elapsed: no verdict from + // the provider, so this is infrastructural, not a rejected grant. + .map_err(|_| AuthError::NetworkUnavailable)?; + let status = resp.status(); + if !status.is_success() { let body = resp.text().await.unwrap_or_default(); - return Err(AgentError::Llm(format!("oauth exchange failed: {body}"))); + // Only a 4xx `invalid_grant` (RFC 6749 §6.4.1) establishes the + // authorization code itself was rejected — the terminal, cooldown-worthy + // `ExchangeFailed`. A 429, any 5xx, and any other/unparseable 4xx are a + // transient provider fault or misconfiguration a cooldown must not + // suppress, so they surface as `NetworkUnavailable` — mirroring the + // refresh classifier, which likewise keys on the body `error`, not the + // bare status class. + if status.is_client_error() + && serde_json::from_str::(&body) + .ok() + .and_then(|v| v.get("error").and_then(Value::as_str).map(str::to_owned)) + .as_deref() + == Some("invalid_grant") + { + tracing::warn!(status = %status, body = %body, "oauth code exchange rejected"); + return Err(AuthError::ExchangeFailed); + } + tracing::warn!(status = %status, body = %body, "oauth code exchange not a grant rejection"); + return Err(AuthError::NetworkUnavailable); } + // A 2xx whose body is missing/malformed or lacks an access token is a + // provider fault, not a rejected grant: it never establishes that the code + // was refused, so it stays in the transient bucket rather than poisoning a + // 5-minute cooldown. let v: Value = resp .json() .await - .map_err(|e| AgentError::Llm(format!("oauth exchange json: {e}")))?; - token_from_response(&v, None) + .map_err(|_| AuthError::NetworkUnavailable)?; + token_from_response(&v, None).map_err(|_| AuthError::NetworkUnavailable) } #[cfg(test)] mod tests { use super::*; + use std::time::Instant; #[test] fn pkce_pair_produces_valid_challenge() { @@ -955,6 +2302,7 @@ mod tests { assert!(token_from_response(&v, None).is_err()); } + #[cfg(unix)] // Disk adoption relies on `write_private_cache`; non-Unix disables disk persistence. #[tokio::test] async fn test_bearer_reuses_disk_token_after_expiry() { let dir = tempfile::tempdir().unwrap(); @@ -996,11 +2344,31 @@ mod tests { assert_eq!(result, "fresh-from-disk"); } + /// A joiner that wakes to the leader's shared *failure* must still recover + /// a sibling's valid replacement from disk. The matching-failure path + /// neutralizes the joiner's own rejected state (under `lock().await`) and + /// then reads the disk lock-free — so a shared failure never forces an + /// N-way browser storm when a sibling already wrote a valid cache entry. + /// + /// The disk replacement is written AFTER B has deterministically joined the + /// slot (held state guard forces the joiner path; poll 1 confirms B is + /// blocked on `state.lock().await`). This ensures the test actually + /// exercises the joiner recovery branch rather than the initial fast-path + /// `cached_hit`. Removing the joiner disk-recovery branch must make the + /// test return Err(RefreshRejected) rather than Ok("sibling-replacement"). + /// + /// Disk-dependent: the replacement lives on disk, so `write_private_cache` + /// must be available (i.e. Unix only). + #[cfg(unix)] #[tokio::test] - async fn test_bearer_falls_through_to_browser_when_disk_also_expired() { + async fn test_joiner_shared_failure_recovers_disk_replacement() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + let dir = tempfile::tempdir().unwrap(); let cfg = PkceOAuthConfig { - discovery_url: "https://example.com/.well-known".into(), + discovery_url: "https://invalid.example.test/.well-known".into(), client_id: "test-client".into(), scopes: vec!["offline_access".into()], cache_namespace: "test".into(), @@ -1008,7 +2376,201 @@ mod tests { }; let source = PkceOAuthTokenSource::new(cfg).unwrap(); - // Expire the in-memory state. + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let replacement = CachedToken { + access_token: "sibling-replacement".into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + // Pre-install a slot for this key and publish the leader's terminal + // failure — digest matches "rejected-bytes" so the joiner enters the + // in-memory neutralization branch. + let key: InflightKey = (source.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + slot.publish( + digest_of(Some("rejected-bytes")), + Err(AuthError::RefreshRejected), + ); + + // Hold the state mutex so the fast-path `try_lock` fails and B is + // forced down the joiner path. The slot is already published, so + // `slot.wait()` returns immediately; B then calls `state.lock().await` + // and suspends while we hold the guard. + let state_guard = source.state.lock().await; + + let mut b_fut = pin!(source.acquire(AuthIntent::Headless, Some("rejected-bytes"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B falls through fast-path (try_lock fails), joins the + // pre-published slot, enters the Err match arm, and blocks on + // `state.lock().await` — structural proof B is on the joiner path. + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is blocked at state.lock().await after waking to Err" + ); + + // Now install the disk replacement. B is definitely past the initial + // fast-path and will only see this token via `usable_from_disk` after + // reconciliation — the recovery branch we are testing. + fs::write( + &source.cache_path, + serde_json::to_vec(&replacement).unwrap(), + ) + .unwrap(); + + // Release the mutex. B acquires the lock, calls expire_rejected_memory + // (empty state — no-op), then reads the disk replacement via + // `usable_from_disk` and returns Ok("sibling-replacement"). + // + // Mutation check: removing the `usable_from_disk` recovery branch + // makes B return Err(RefreshRejected) instead — the assertion fails. + drop(state_guard); + + let result = b_fut.await; + inflight_registry().remove(&key); + + assert_eq!( + result, + Ok("sibling-replacement".to_string()), + "the joiner must read the disk replacement and not inherit the shared failure — \ + mutation check: removing the usable_from_disk branch returns Err(RefreshRejected)" + ); + } + + /// **Joiner failure cleanup must not modify the shared disk cache.** + /// + /// The matching-failure joiner calls `expire_rejected_memory` (in-process + /// state only). It must not write, truncate, rename, or remove the disk + /// cache. An independent process C may have persisted a valid replacement + /// under the cross-process file lock between A's failure and B's + /// reconciliation; an unfenced disk write from B would overwrite it. + /// + /// This test seeds X on disk, runs B as a joiner that wakes to a matching + /// failure, and asserts the disk file is byte-for-byte unchanged afterward. + /// + /// Mutation check: reverting the joiner arm to call `expire_rejected` + /// instead of `expire_rejected_memory` makes B read the disk file, see + /// `access_token == "rejected-X"`, set `expires_at = 0`, and overwrite the + /// file via `persist` or in-place truncate. The disk bytes change, and the + /// "disk unchanged" assertion fails — proving the unfenced write is exactly + /// the race that would overwrite any concurrent C write that landed between + /// A's failure and B's reconciliation. + #[cfg(unix)] + #[tokio::test] + async fn test_joiner_failure_does_not_write_disk() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let cfg = PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }; + let b = PkceOAuthTokenSource::new(cfg).unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + + // Seed X on disk. The constructor may not create the parent directory + // without a pre-existing file, so ensure it exists first. + let token_x = CachedToken { + access_token: "rejected-X".into(), + refresh_token: Some("live-refresh".into()), + expires_at: Some(future_exp), + }; + if let Some(parent) = b.cache_path.parent() { + fs::create_dir_all(parent).unwrap(); + } + let disk_before = serde_json::to_vec(&token_x).unwrap(); + fs::write(&b.cache_path, &disk_before).unwrap(); + + // Pre-install a matching-failure slot (digest matches "rejected-X"). + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + slot.publish( + digest_of(Some("rejected-X")), + Err(AuthError::RefreshRejected), + ); + + // Hold B's state mutex: fast-path try_lock fails → joiner path; + // state.lock().await during reconciliation blocks until we drop. + let state_guard = b.state.lock().await; + + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("rejected-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B falls through fast-path, joins the pre-published slot, + // wakes to Err, and parks at state.lock().await. + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is parked at state.lock().await after waking to Err" + ); + + // Release the state guard. B acquires the lock, calls + // expire_rejected_memory (in-memory neutralization only — no disk I/O), + // then checks usable_from_disk. The disk token's access_token is + // "rejected-X" which equals `rejected`, so usable_from_disk filters it + // and returns None. B returns Err(RefreshRejected). + drop(state_guard); + + let result = b_fut.await; + inflight_registry().remove(&key); + + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "B must propagate the shared failure" + ); + + // The disk file must be byte-for-byte identical to what was seeded. + // expire_rejected_memory must not have touched it. + // + // Mutation check: expire_rejected reads the disk file, finds + // access_token == "rejected-X", sets expires_at = 0, and rewrites + // the file. The bytes change and this assertion fails — proving the + // unfenced write is the exact race that overwrites a concurrent C write + // landing between A's failure and B's reconciliation. + let disk_after = fs::read(&b.cache_path).unwrap(); + assert_eq!( + disk_after, disk_before, + "joiner failure cleanup must not modify the disk cache — \ + mutation check: expire_rejected rewrites the file (expires_at=0), \ + overwriting any concurrent write from process C" + ); + } + + #[tokio::test] + async fn test_bearer_headless_no_credential_is_terminal_without_browser() { + let dir = tempfile::tempdir().unwrap(); + let cfg = PkceOAuthConfig { + // Unreachable discovery URL: if bearer() ever attempts discovery or + // a browser flow, this test would hang or error differently. The + // headless path must not touch either. + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }; + let source = PkceOAuthTokenSource::new(cfg).unwrap(); + + // Expire the in-memory state with no refresh token. { let mut state = source.state.lock().await; *state = Some(CachedToken { @@ -1018,7 +2580,7 @@ mod tests { }); } - // Write an expired token to disk too. + // Write an expired, refresh-less token to disk too. let expired_token = CachedToken { access_token: "also-stale".into(), refresh_token: None, @@ -1027,27 +2589,25 @@ mod tests { let body = serde_json::to_vec_pretty(&expired_token).unwrap(); fs::write(&source.cache_path, &body).unwrap(); - // bearer() should fall through past the disk check. - // It will fail at the endpoints() discovery call since there's no server, - // which proves it didn't short-circuit on the expired disk token. - let result = source.bearer().await; - assert!(result.is_err()); - let err_msg = format!("{}", result.unwrap_err()); - assert!( - err_msg.contains("oauth discovery"), - "expected discovery error, got: {err_msg}" - ); + // bearer() is a Headless acquisition: past the cache checks with no + // refresh token, it returns terminally instead of opening a browser. + // With no refresh token it never even discovers endpoints, so the + // unreachable URL is never contacted — the error is a graceful + // LlmAuth, not a hard Llm/discovery error. + match source.bearer().await.unwrap_err() { + AgentError::LlmAuth(_) => {} // correct: terminal, no browser + other => panic!("expected terminal LlmAuth, got: {other:?}"), + } } - /// `try_bearer_no_browser` with an empty cache and no refresh token must + /// `bearer_no_browser` with an empty cache and no refresh token must /// return `LlmAuth` immediately — it must NOT attempt OIDC discovery even - /// when the `discovery_url` is unreachable/invalid. This guards the - /// regression where `endpoints()` was called unconditionally before the - /// refresh-token check, causing an `Llm` error (hard failure) instead of - /// the intended graceful `LlmAuth` fallback. + /// when the `discovery_url` is unreachable/invalid, and must never browse. + /// This guards the regression where `endpoints()` was called + /// unconditionally before the refresh-token check, causing an `Llm` error + /// (hard failure) instead of the intended graceful `LlmAuth` fallback. #[tokio::test] - async fn test_try_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery() - { + async fn test_bearer_no_browser_empty_cache_no_refresh_returns_llm_auth_without_discovery() { let dir = tempfile::tempdir().unwrap(); // Intentionally invalid/unreachable discovery URL — if endpoints() is // called, the test will get an `Llm` error and the assertion below fails. @@ -1069,7 +2629,7 @@ mod tests { // No disk cache file either — dir is empty. - let result = source.try_bearer_no_browser().await; + let result = source.bearer_no_browser().await; assert!(result.is_err(), "expected Err, got Ok"); match result.unwrap_err() { AgentError::LlmAuth(_) => {} // correct: graceful fallback @@ -1395,4 +2955,311 @@ mod tests { "read_cache followed a symlinked cache path" ); } + + // ---- cross-process advisory lock primitive -------------------------- + // + // The full 165s waiter bound (`LOCK_WAIT_TIMEOUT`) is not exercisable in a + // unit test, so these drive `acquire_auth_lock` with explicit deadlines to + // pin the three properties the coordinator relies on: a contended waiter + // times out (never blocks forever), a timeout leaves the *holder* + // untouched (never cancels the in-flight attempt), and releasing the + // holder — the RAII stand-in for a crashed process — lets a successor + // proceed with no wedge and no lock-breaking. + + #[tokio::test] + async fn test_lock_wait_times_out_and_leaves_holder_untouched() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cache.json.lock"); + + // Holder takes the lock with a generous deadline. + let holder = acquire_auth_lock(&path, Instant::now() + Duration::from_secs(30)) + .await + .expect("holder should acquire the free lock"); + + // A waiter with an already-lapsed deadline must give up with + // LockTimeout rather than block — this is the deadline-aware polling + // that replaces a blocking `lock()`. + let waiter = acquire_auth_lock(&path, Instant::now()).await; + assert!( + matches!(waiter, Err(AuthError::LockTimeout)), + "contended waiter past its deadline must return LockTimeout, got {waiter:?}" + ); + + // The timeout did not cancel or steal the holder: a second immediate + // waiter still cannot acquire, proving the holder is intact. + let still_held = acquire_auth_lock(&path, Instant::now()).await; + assert!( + matches!(still_held, Err(AuthError::LockTimeout)), + "holder must remain intact after a waiter times out, got {still_held:?}" + ); + + drop(holder); + } + + #[tokio::test] + async fn test_lock_timeout_leaves_cooldown_sidecar_byte_for_byte_untouched() { + let dir = tempfile::tempdir().unwrap(); + let lock_path = dir.path().join("cache.json.lock"); + let cooldown_path = dir.path().join("cache.json.cooldown"); + + // A pre-existing cooldown sidecar written by an earlier interactive + // failure. A waiter that can't take the lock must return before any + // code that reads/clears/writes the cooldown, so these exact bytes + // survive untouched — otherwise a lock-contended caller could clear a + // live suppression and let the next Auto caller re-pop a browser. + let original = br#"{"code":"denied","until":9999999999}"#; + fs::write(&cooldown_path, original).unwrap(); + + // Holder owns the lock (RAII stand-in for another live process). + let holder = acquire_auth_lock(&lock_path, Instant::now() + Duration::from_secs(30)) + .await + .expect("holder should acquire the free lock"); + + // A waiter past its deadline gives up with LockTimeout — the `?` in + // `acquire_leader` propagates this before `acquire_locked` (which owns + // every sidecar mutation) is ever entered. + let waiter = acquire_auth_lock(&lock_path, Instant::now()).await; + assert!( + matches!(waiter, Err(AuthError::LockTimeout)), + "contended waiter past its deadline must return LockTimeout, got {waiter:?}" + ); + + let after = fs::read(&cooldown_path).unwrap(); + assert_eq!( + after.as_slice(), + original.as_slice(), + "a lock timeout must leave the cooldown sidecar byte-for-byte untouched" + ); + + drop(holder); + } + + /// **Awaited reconciliation is falsifiable — `lock().await` cannot regress to `try_lock`.** + /// + /// Deterministic direct-poll proof: the test task holds B's state mutex and + /// manually polls a pinned real `acquire()` future at each state transition, + /// without spawning a task or relying on scheduler ordering. + /// + /// Proof sequence: + /// 1. Seed B's state with stale X; register an unpublished slot. + /// 2. Hold B's state mutex — blocks the fast-path `try_lock` so B falls + /// through to the registry, and will block `lock().await` when B tries + /// to reconcile after waking. + /// 3. Poll B's `acquire()` once: no prior async suspension on the joiner + /// path, so B reaches `slot.wait()`'s inner `rx.changed().await` and + /// parks — the poll returns `Pending`. This is a structural proof, not a + /// scheduler assumption. + /// 4. Publish Y and poll the same future again while the state mutex is + /// still held. `slot.wait()` wakes and returns; B calls + /// `state.lock().await`, which must park because we hold the mutex → + /// this poll returns `Pending`. + /// Mutation check: with `try_lock()` the adopt block is skipped and B + /// returns immediately → this poll returns `Ready(Ok("token-Y"))`, + /// failing the `Pending` assertion. + /// 5. Release the state guard; poll to completion (or `await` the future) + /// and assert the result is `Ok("token-Y")`. + /// 6. Assert a subsequent plain `acquire(None)` returns Y from the + /// in-memory cache — the P1 contract. + /// Mutation check: `try_lock` leaves state == stale X, so this acquire + /// returns X — the exact P1 stale-credential regression. + #[tokio::test] + async fn test_joiner_reconciliation_blocked_until_state_lock_released() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let b = PkceOAuthTokenSource::new(PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }) + .unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let make_token = |access: &str| CachedToken { + access_token: access.into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + let token_x = make_token("token-X"); // B's stale/rejected credential. + let token_y = make_token("token-Y"); // shared leader result — must replace X. + + // Seed B's state with stale X. + { + let mut state = b.state.lock().await; + *state = Some(token_x.clone()); + } + + // Register an unpublished slot so B will join it. + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + + // Hold B's state mutex. + // (a) The fast-path `try_lock` fails → B falls through to the joiner path. + // (b) `state.lock().await` during reconciliation will block until we drop. + let state_guard = b.state.lock().await; + + // Pin B's acquire() future in this stack frame for manual polling. + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("token-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + + // Poll 1: B has no async suspension before `slot.wait()`'s inner + // `rx.changed().await`. The slot is unpublished, so `changed()` parks. + // Result must be Pending — structural proof that B reached slot.wait(). + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 1 must be Pending: B is parked at slot.wait() awaiting publication" + ); + + // Publish Y. `rx.changed()` wakes; on the next poll B exits slot.wait(), + // enters reconciliation, and calls `state.lock().await`. + slot.publish(None, Ok(token_y.clone())); + inflight_registry().remove(&key); + + // Poll 2: `slot.wait()` returns Y; B calls `state.lock().await`. + // With `lock().await`: the mutex is held → parks → Pending. + // Mutation (`try_lock`): try_lock fails → adopt skipped → B returns + // Ok("token-Y") immediately → Ready, not Pending. + // + // This poll is the exact mutation discriminator: Ready here is the + // bug (B completed without awaited reconciliation). + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "poll 2 must be Pending: B must not return while state mutex is held — \ + mutation check: `try_lock()` returns Ready here, proving early completion \ + without reconciliation (the P1 regression)" + ); + + // Release the mutex. B acquires the lock, evaluates the adoption + // predicate (state == stale X, matches the rejected token), writes Y, + // and returns Ok("token-Y"). + drop(state_guard); + + // Await completion (B now owns the mutex). + let result = b_fut.await; + assert_eq!( + result, + Ok("token-Y".to_string()), + "B must return the shared token Y after reconciliation completes" + ); + + // Subsequent plain acquire must return Y from the in-memory cache — + // the P1 contract. With the `try_lock` mutation, state still holds X + // and this acquire returns X (stale-credential regression). + let rb_next = b + .acquire(AuthIntent::Headless, None) + .await + .expect("subsequent acquire must return Y from in-memory state"); + assert_eq!( + rb_next, "token-Y", + "subsequent in-memory read must return Y, not stale X — \ + mutation check: `try_lock()` leaves state == X, returning X" + ); + } + + /// **Preserve-distinct-newer — reconciliation must not overwrite B's valid credential.** + /// + /// B already holds a valid, usable token Z (distinct from rejected X and from the + /// leader's shared result Y) in its `state` when the joiner reconciliation runs. + /// The adoption predicate must evaluate to false for Z and leave it in place. + /// + /// Deterministic setup via direct polling: register an unpublished slot; poll + /// B's `acquire()` once to park it at `slot.wait()`; write Z into B's state; + /// publish Y and await completion. No scheduler inference or `yield_now()`. + /// + /// Mutation check (unconditional adoption): if the reconciliation block writes + /// `*state = Some(token.clone())` unconditionally, Z is overwritten with Y. + /// The subsequent state assertion `state == Z` FAILS — proving the predicate + /// is load-bearing. + #[tokio::test] + async fn test_joiner_preserve_distinct_newer_credential() { + use std::future::Future as _; + use std::pin::pin; + use std::task::{Context, Poll, Waker}; + + let dir = tempfile::tempdir().unwrap(); + let b = PkceOAuthTokenSource::new(PkceOAuthConfig { + discovery_url: "https://invalid.example.test/.well-known".into(), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "test".into(), + cache_dir_override: Some(dir.path().to_path_buf()), + }) + .unwrap(); + + let future_exp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 7200; + let make_token = |access: &str| CachedToken { + access_token: access.into(), + refresh_token: Some("rt".into()), + expires_at: Some(future_exp), + }; + + let token_z = make_token("token-Z"); // B's distinct, independently acquired credential. + let token_y = make_token("token-Y"); // leader's shared result — must NOT overwrite Z. + + // Register a not-yet-published slot so B will join it and wait. + // B starts with empty state so its fast-path cache miss is guaranteed. + let key: InflightKey = (b.lock_path(), AuthIntent::Headless); + let slot = Arc::new(InflightSlot::new()); + inflight_registry().insert(key.clone(), slot.clone()); + + // Pin B's future and poll once to park it at slot.wait(). + // No async suspension precedes slot.wait() on the joiner path, so the + // first poll is the structural proof that B is parked there. + let mut b_fut = pin!(b.acquire(AuthIntent::Headless, Some("token-X"))); + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + assert!( + matches!(b_fut.as_mut().poll(&mut cx), Poll::Pending), + "B must park at slot.wait() on the first poll" + ); + + // B is now suspended in slot.wait(). Write Z into B's state — this is an + // intervening write that B will observe when it evaluates the + // reconciliation predicate after waking. + { + let mut state = b.state.lock().await; + *state = Some(token_z.clone()); + } + + // Publish Y to wake B. B will call lock().await, see Z (not expired, not + // matching "token-X"), evaluate the predicate as false, and preserve Z. + slot.publish(None, Ok(token_y.clone())); + inflight_registry().remove(&key); + + let result = b_fut.await; + + assert_eq!( + result, + Ok("token-Y".to_string()), + "B must still receive the shared bearer Y" + ); + + // B.state must still hold Z — the adoption predicate correctly skipped + // the write because Z is usable and distinct from the rejected token. + { + let state = b.state.lock().await; + assert_eq!( + state.as_ref().map(|t| t.access_token.as_str()), + Some("token-Z"), + "B.state must not be overwritten when it holds a distinct usable credential — \ + mutation check: fails if reconciliation is unconditional \ + (overwrites Z with Y regardless of predicate)" + ); + } + } } diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index 202d73e5548..5b2f1d659d2 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -709,7 +709,7 @@ impl Config { max_output_tokens: parse_env("BUZZ_AGENT_MAX_OUTPUT_TOKENS", 65_536)?, max_token_recoveries: parse_env("BUZZ_AGENT_MAX_TOKEN_RECOVERIES", 3u32)?, llm_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_LLM_TIMEOUT_SECS", 240)?), - tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 660)?), + tool_timeout: Duration::from_secs(parse_env("BUZZ_AGENT_TOOL_TIMEOUT_SECS", 1_260)?), mcp_init_timeout: Duration::from_secs(parse_env( "BUZZ_AGENT_MCP_INIT_TIMEOUT_SECS", 30, @@ -2462,4 +2462,24 @@ mod tests { assert_eq!(pricing_authority("https://api.databricks.com/v1"), None); assert_eq!(pricing_authority("https://custom.llm.corp/v1"), None); } + + #[test] + fn default_tool_timeout_is_1260_seconds() { + // Lock the production default so accidental regressions are caught. + // This value must remain >= buzz-dev-mcp's MAX_TIMEOUT_MS (1_200s) to + // give every shell(timeout_ms=1_200_000) call time to complete before + // buzz-agent kills the MCP server. See PR #7185 for the full budget chain. + // + // 1_260s is the literal default passed to parse_env in Config::from_env(). + // Update here if and only if you update that literal; the test name makes + // "grep for old value" reliable. + const DEFAULT_TOOL_TIMEOUT_SECS: u64 = 1_260; + const { + // Shell cap (1_200_000 ms = 1_200s) must fit inside the agent timeout. + assert!( + 1_200u64 <= DEFAULT_TOOL_TIMEOUT_SECS, + "agent tool timeout must be >= dev-mcp shell cap (1200s)" + ); + } + } } diff --git a/crates/buzz-agent/src/model_capabilities.rs b/crates/buzz-agent/src/model_capabilities.rs index b0e4ebc6e50..53f2d290ff6 100644 --- a/crates/buzz-agent/src/model_capabilities.rs +++ b/crates/buzz-agent/src/model_capabilities.rs @@ -634,6 +634,7 @@ mod tests { Q::Vector { id: "resolver-exact-raw-id-probe", provider: "databricks_v2", raw_model_id: "databricks-gpt-5-4-mini", note: Some("Probes a raw id that has an exact record.") }, Q::Vector { id: "dbv2-claude-fable-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-fable-5", note: Some("Probes the canonical Databricks Fable 5 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-fable-5-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-fable-5", note: Some("Probes a prefixed alias of the Databricks Fable 5 endpoint.") }, + Q::Vector { id: "dbv2-claude-fable-5-1-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-fable-5-1", note: Some("Probes the canonical Databricks Fable 5.1 endpoint record.") }, Q::Vector { id: "dbv2-claude-opus-4-8-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-4-8", note: Some("Probes the canonical Databricks Opus 4.8 endpoint record.") }, Q::Vector { id: "dbv2-goose-claude-opus-4-8-alias-probe", provider: "databricks_v2", raw_model_id: "goose-claude-opus-4-8", note: Some("Probes a prefixed alias of the Databricks Opus 4.8 endpoint.") }, Q::Vector { id: "dbv2-claude-opus-5-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-claude-opus-5", note: Some("Probes the canonical Databricks Opus 5 endpoint record.") }, @@ -743,7 +744,7 @@ mod tests { Q::Vector { id: "dbv2-inkling-exact-record-probe", provider: "databricks_v2", raw_model_id: "databricks-inkling", note: Some("Probes the Inkling endpoint record and label.") }, Q::Vector { id: "dbv2-uc-fqn-gemini-3-5-flash-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.gemini-3-5-flash", note: Some("Probes strip parity on a system.ai. UC FQN carrying the gemini- token (resolve carries no label; the alias label path is unit-tested).") }, Q::Vector { id: "dbv2-uc-fqn-meta-llama-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.meta-llama-3-3-70b-instruct", note: Some("Probes strip parity on a UC FQN where the llama- token strips through meta-.") }, - Q::Vector { id: "dbv2-uc-goose-deepseek-strip-probe", provider: "databricks_v2", raw_model_id: "data_workflow_tools.goose.goose-deepseek-v4-pro-0813", note: Some("Probes strip parity on a goose- prefixed UC FQN carrying the deepseek- token.") }, + Q::Vector { id: "dbv2-uc-fqn-deepseek-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.deepseek-v4-pro-0813", note: Some("Probes strip parity on a UC FQN carrying the deepseek- token.") }, Q::Vector { id: "dbv2-uc-fqn-inkling-strip-probe", provider: "databricks_v2", raw_model_id: "system.ai.inkling", note: Some("Probes strip parity on a UC FQN carrying the bare inkling token.") }, Q::Section { group: "Label/capability token isolation probes (#6955 review pass 1)", note: Some("Pins that label_family_tokens (the UC-humanization superset) never leaks into capability resolve(): capability stripping still uses only claude-/gpt-/kimi-, so a label token appearing before a gpt- marker must NOT displace the gpt-5-pro exact profile.") }, Q::Vector { id: "isolation-openai-gemini-gpt-5-pro-probe", provider: "openai", raw_model_id: "tenant-gemini-gpt-5-pro", note: Some("The gemini- label token must not strip here; capability resolve keeps the gpt-5-pro high-only profile.") }, @@ -843,7 +844,7 @@ mod tests { } #[test] - fn corpus_has_exactly_139_executable_vectors() { + fn corpus_has_exactly_140_executable_vectors() { // Locks the vector count so a silent INPUTS edit can't quietly drop // coverage; must equal the gate in the TS harness // (modelCapabilitiesCorpus.test.mjs). @@ -852,7 +853,7 @@ mod tests { .filter(|q| matches!(q, Q::Vector { .. })) .count(); assert_eq!( - vectors, 139, + vectors, 140, "corpus executable-vector count changed; update this gate deliberately" ); } @@ -873,7 +874,7 @@ mod tests { #[test] fn databricks_v2_fqn_uses_neutral_concrete_unknown_capabilities() { - let fqn = resolve("databricks_v2", "data_workflow_tools.goose.goose-kimi-k3"); + let fqn = resolve("databricks_v2", "system.ai.kimi-k3"); let fallback = resolve("databricks_v2", "some-unknown-xyz"); assert_eq!(fqn.thinking_mode, fallback.thinking_mode); assert_eq!(fqn.supported_efforts, fallback.supported_efforts); @@ -1056,16 +1057,10 @@ mod tests { ("system.ai.qwen35-122b-a10b", "Qwen3.5 122B A10B"), ("system.ai.gemma-3-12b", "Gemma 3 12B"), ("system.ai.inkling", "Inkling"), - ( - "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", - "DeepSeek V4 Flash", - ), - ("data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"), - ( - "data_workflow_tools.goose.goose-glm-5-3-flash", - "GLM-5.3 Flash", - ), - ("data_workflow_tools.goose.goose-grok-4-6", "Grok 4.6"), + ("system.ai.deepseek-v4-flash-0731", "DeepSeek V4 Flash"), + ("system.ai.glm-5-3", "GLM-5.3"), + ("system.ai.glm-5-3-flash", "GLM-5.3 Flash"), + ("system.ai.grok-4-6", "Grok 4.6"), ] { assert_eq!(databricks_registry_label(fqn), Some(label), "fqn={fqn}"); } diff --git a/crates/buzz-agent/tests/bin/auth_worker.rs b/crates/buzz-agent/tests/bin/auth_worker.rs new file mode 100644 index 00000000000..5a4b76d2866 --- /dev/null +++ b/crates/buzz-agent/tests/bin/auth_worker.rs @@ -0,0 +1,252 @@ +//! Test-only helper: a real second process that runs the PUBLIC auth +//! coordinator (`PkceOAuthTokenSource::acquire_with_intent`) against a shared +//! temp cache, so the auth tests can prove the *cross-process* single-flight +//! contract end-to-end rather than with two in-process handles. +//! +//! The in-process `INFLIGHT` registry coalesces same-key callers within one +//! process before they ever reach the file lock, so two `PkceOAuthTokenSource` +//! instances in one test do NOT exercise the cross-process protocol (the OS +//! advisory lock and the on-disk cache re-read). This binary is a genuine +//! second process: it contends on the same `flock`/`LockFileEx` and reads/writes +//! the same private cache file the parent coordinator does. +//! +//! The browser step is scripted (no real window): the opener drives the +//! loopback callback exactly as a real browser would, and its launch count is +//! reported back so a test can assert "exactly one browser across processes". +//! +//! Env contract (all required unless noted): +//! AUTH_WORKER_DISCOVERY_URL — OIDC discovery URL (the parent stub). +//! AUTH_WORKER_CACHE_DIR — shared cache dir (`cache_dir_override`). +//! AUTH_WORKER_NAMESPACE — cache namespace. +//! AUTH_WORKER_CLIENT_ID — OAuth client id. +//! AUTH_WORKER_SCOPES — comma-separated scopes. +//! AUTH_WORKER_INTENT — auto | userinitiated | headless. +//! AUTH_WORKER_SCRIPT — approve | deny | failopen. +//! AUTH_WORKER_RESULT — path to write the JSON outcome to. +//! AUTH_WORKER_REJECTED — (optional) rejected token bytes passed to +//! `acquire_with_intent`; absent means no rejection. +//! AUTH_WORKER_READY_MARKER — (optional) written once the source is built, +//! before acquisition, so the parent can release +//! several workers into a genuine lock race. +//! AUTH_WORKER_START_MARKER — (optional) acquisition blocks until this file +//! exists, so multiple workers begin together. +//! AUTH_WORKER_LAUNCHED_MARKER — (optional) written when the browser opener +//! fires (i.e. this process holds the lock and is +//! mid-flow), so the parent can queue behind it. +//! AUTH_WORKER_PROCEED_MARKER — (optional) the scripted callback is withheld +//! until this file exists, so the parent can +//! confirm another process is already waiting on +//! the lock before this one resolves. +//! AUTH_WORKER_SNAPSHOT_MARKER — (optional) a file path; when set, a tracing +//! layer intercepts the `acquire_leader_snapshot` +//! event emitted by `auth.rs` after the attempt- +//! generation snapshot is taken (and before the +//! cross-process lock is acquired) and writes this +//! file once. Lets the parent observe that this +//! process has committed its snapshot-gen and is +//! about to queue on the lock. +//! +//! Result JSON: `{ "result": "ok"|"", "bearer": , +//! "launches": }`. + +use std::fs; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use buzz_agent::auth::{AuthIntent, BrowserOpener, PkceOAuthConfig, PkceOAuthTokenSource}; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; + +/// Tracing layer that writes a file once when it sees the +/// `buzz_agent::auth::acquire_leader_snapshot` event emitted by +/// `acquire_leader` immediately after the attempt-generation snapshot is fixed +/// and before the cross-process lock is acquired. Installed only when +/// `AUTH_WORKER_SNAPSHOT_MARKER` is set, so normal test runs incur no overhead. +struct SnapshotMarkerLayer { + path: PathBuf, + written: AtomicBool, +} + +impl tracing_subscriber::Layer for SnapshotMarkerLayer { + fn on_event( + &self, + event: &tracing::Event<'_>, + _ctx: tracing_subscriber::layer::Context<'_, S>, + ) { + if event.metadata().target() == "buzz_agent::auth::acquire_leader_snapshot" + && !self.written.swap(true, Ordering::SeqCst) + { + let _ = fs::write(&self.path, b"snapshotted"); + } + } +} + +/// What the scripted "user" does when the coordinator opens a browser. +#[derive(Clone, Copy)] +enum Script { + Approve, + Deny, + FailToOpen, +} + +/// A [`BrowserOpener`] that counts launches and drives the loopback callback on +/// a background thread — the same technique as the in-crate test opener, but +/// with two optional cross-process barriers so the parent can order events: +/// `launched_marker` announces that this process holds the lock and has opened +/// the browser, and `proceed_marker` withholds the callback until the parent +/// signals it has queued another process behind the lock. +struct WorkerOpener { + script: Script, + calls: Arc, + launched_marker: Option, + proceed_marker: Option, +} + +impl BrowserOpener for WorkerOpener { + fn open(&self, url: &str) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(marker) = &self.launched_marker { + fs::write(marker, b"launched").expect("write launched marker"); + } + let query = match self.script { + Script::FailToOpen => return Err("no browser available".into()), + Script::Approve => "code=scripted-code", + Script::Deny => "error=access_denied", + }; + let parsed = url::Url::parse(url).expect("authorize URL must parse"); + let redirect = parsed + .query_pairs() + .find(|(k, _)| k == "redirect_uri") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries redirect_uri"); + let state = parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries state"); + let redirect = url::Url::parse(&redirect).expect("redirect_uri must parse"); + let port = redirect.port().expect("loopback redirect carries a port"); + let request = format!( + "GET /?{query}&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + let proceed = self.proceed_marker.clone(); + std::thread::spawn(move || { + // Hold the callback until the parent has confirmed another process + // is already queued behind the lock (bounded so a missing signal + // can't wedge the test past the browser timeout). + if let Some(marker) = proceed { + for _ in 0..6000 { + if marker.exists() { + break; + } + std::thread::sleep(Duration::from_millis(10)); + } + } + if let Ok(mut sock) = TcpStream::connect(("127.0.0.1", port)) { + let _ = sock.write_all(request.as_bytes()); + let _ = sock.flush(); + let mut discard = Vec::new(); + let _ = sock.read_to_end(&mut discard); + } + }); + Ok(()) + } +} + +fn env(key: &str) -> String { + std::env::var(key).unwrap_or_else(|_| panic!("{key} set")) +} + +#[tokio::main] +async fn main() { + // If the parent test set AUTH_WORKER_SNAPSHOT_MARKER, install a tracing + // subscriber layer that fires when the coordinator emits its pre-lock + // snapshot event and writes the marker file. + if let Ok(marker_path) = std::env::var("AUTH_WORKER_SNAPSHOT_MARKER") { + tracing_subscriber::registry() + .with(SnapshotMarkerLayer { + path: PathBuf::from(marker_path), + written: AtomicBool::new(false), + }) + .init(); + } + + let intent = match env("AUTH_WORKER_INTENT").as_str() { + "auto" => AuthIntent::Auto, + "userinitiated" => AuthIntent::UserInitiated, + "headless" => AuthIntent::Headless, + other => panic!("unknown AUTH_WORKER_INTENT: {other}"), + }; + let script = match env("AUTH_WORKER_SCRIPT").as_str() { + "approve" => Script::Approve, + "deny" => Script::Deny, + "failopen" => Script::FailToOpen, + other => panic!("unknown AUTH_WORKER_SCRIPT: {other}"), + }; + let result_path = PathBuf::from(env("AUTH_WORKER_RESULT")); + let start_marker = std::env::var("AUTH_WORKER_START_MARKER") + .ok() + .map(PathBuf::from); + let ready_marker = std::env::var("AUTH_WORKER_READY_MARKER") + .ok() + .map(PathBuf::from); + + let calls = Arc::new(AtomicU64::new(0)); + let opener = WorkerOpener { + script, + calls: calls.clone(), + launched_marker: std::env::var("AUTH_WORKER_LAUNCHED_MARKER") + .ok() + .map(PathBuf::from), + proceed_marker: std::env::var("AUTH_WORKER_PROCEED_MARKER") + .ok() + .map(PathBuf::from), + }; + + let cfg = PkceOAuthConfig { + discovery_url: env("AUTH_WORKER_DISCOVERY_URL"), + client_id: env("AUTH_WORKER_CLIENT_ID"), + scopes: env("AUTH_WORKER_SCOPES") + .split(',') + .map(str::to_owned) + .collect(), + cache_namespace: env("AUTH_WORKER_NAMESPACE"), + cache_dir_override: Some(PathBuf::from(env("AUTH_WORKER_CACHE_DIR"))), + }; + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener)).expect("build token source"); + + // Announce readiness, then wait for the parent's release so several workers + // hit the lock together — a genuine race rather than staggered spawns. + if let Some(marker) = &ready_marker { + fs::write(marker, b"ready").expect("write ready marker"); + } + if let Some(marker) = start_marker { + for _ in 0..6000 { + if marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + let (result, bearer) = match src + .acquire_with_intent( + intent, + std::env::var("AUTH_WORKER_REJECTED").ok().as_deref(), + ) + .await + { + Ok(token) => ("ok".to_owned(), Some(token)), + Err(e) => (e.code().to_owned(), None), + }; + let body = serde_json::json!({ + "result": result, + "bearer": bearer, + "launches": calls.load(Ordering::SeqCst), + }); + fs::write(&result_path, serde_json::to_vec(&body).unwrap()).expect("write result file"); +} diff --git a/crates/buzz-agent/tests/bin/lock_holder.rs b/crates/buzz-agent/tests/bin/lock_holder.rs new file mode 100644 index 00000000000..275503a762c --- /dev/null +++ b/crates/buzz-agent/tests/bin/lock_holder.rs @@ -0,0 +1,50 @@ +//! Test-only helper: a real second process that takes the coordinator's +//! cross-process advisory lock and holds it until killed. +//! +//! The auth coordinator single-flights per cache key on an `fs2` advisory lock +//! (`flock` on Unix, `LockFileEx` on Windows). To prove the *cross-process* +//! contract — a genuine other process serializes the flow, and its death +//! releases the lock with no PID files or lock-breaking — a test needs an +//! actual separate process on the same lock file, not a second in-process +//! handle. This binary is that process. +//! +//! Driven by two env vars: +//! LOCK_HELPER_PATH — the lock file to acquire (the coordinator's +//! `.json.lock`). +//! LOCK_HELPER_READY — a marker file created *after* the lock is held, so +//! the parent test can synchronize on ownership before +//! racing the coordinator. +//! +//! After signaling readiness it blocks forever; the parent kills it to model a +//! crash mid-flow. + +use std::fs; + +use fs2::FileExt; + +fn main() { + let lock_path = std::env::var("LOCK_HELPER_PATH").expect("LOCK_HELPER_PATH set"); + let ready_path = std::env::var("LOCK_HELPER_READY").expect("LOCK_HELPER_READY set"); + + if let Some(parent) = std::path::Path::new(&lock_path).parent() { + fs::create_dir_all(parent).expect("create lock parent dir"); + } + // Open exactly as the coordinator does so we contend on the same inode. + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock_path) + .expect("open lock file"); + file.lock_exclusive() + .expect("hold the exclusive advisory lock"); + + // Signal ownership only once the lock is truly held. + fs::write(&ready_path, b"held").expect("write ready marker"); + + // Hold the lock until the parent kills us (crash stand-in). The kernel + // releases the advisory lock on process death. + loop { + std::thread::sleep(std::time::Duration::from_secs(3600)); + } +} diff --git a/crates/buzz-agent/tests/databricks_auth_coordinator.rs b/crates/buzz-agent/tests/databricks_auth_coordinator.rs new file mode 100644 index 00000000000..0937cc87c1d --- /dev/null +++ b/crates/buzz-agent/tests/databricks_auth_coordinator.rs @@ -0,0 +1,3410 @@ +//! Concurrency-matrix tests for the Databricks auth coordinator. +//! +//! The coordinator single-flights OAuth acquisition per cache key. Within one +//! process, same-key callers coalesce on an in-memory `INFLIGHT` registry +//! *before* the file lock; across processes, they serialize on an OS advisory +//! lock and share success through the on-disk cache, with failures coalesced +//! through a durable cooldown sidecar. These tests drive the public API +//! (`acquire_with_intent`, `interactive_login`) with an injected +//! [`BrowserOpener`] that scripts the localhost callback instead of popping a +//! real window — the browser step becomes deterministic and countable. +//! +//! Two `PkceOAuthTokenSource` instances in ONE process do not model two +//! processes: the `INFLIGHT` registry intercepts them before the file lock, so +//! same-process tests exercise the in-memory single-flight, not the +//! cross-process protocol. The genuinely cross-process claims — lock +//! contention, crash release, cooldown sharing across a process boundary, and +//! one-grant/one-cache under a real race — are proved with the `lock-holder` +//! and `auth-worker` helper binaries, each a real second process on the same +//! lock file and cache. The lock-primitive and lock-timeout edges live in the +//! in-crate `auth::tests` module where the private helpers are reachable. + +use std::io::Write; +use std::net::{SocketAddr, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use axum::extract::Form; +use axum::{routing::get, routing::post, Json, Router}; +use buzz_agent::auth::{ + AuthError, AuthIntent, BrowserOpener, PkceOAuthConfig, PkceOAuthTokenSource, +}; +use serde::Deserialize; +use serde_json::json; +use tempfile::TempDir; + +// ---- scripted browser opener -------------------------------------------- + +/// What the scripted "user" does when the coordinator opens a browser. +#[derive(Clone, Copy)] +enum Script { + /// Redirect with a valid `code`+`state` → the flow exchanges it for a + /// token and succeeds. + Approve, + /// Redirect with `error=access_denied` → the flow returns `Denied`. + Deny, + /// Every launch strategy fails → the flow returns `BrowserOpenFailed` + /// without waiting on a listener nobody will reach. + FailToOpen, +} + +/// A [`BrowserOpener`] that counts launches and drives the localhost callback +/// on a background thread, so the caller's callback wait observes the redirect +/// exactly as a real browser would deliver it. +#[derive(Clone)] +struct ScriptedOpener { + script: Script, + calls: Arc, +} + +impl ScriptedOpener { + fn new(script: Script) -> Self { + Self { + script, + calls: Arc::new(AtomicU64::new(0)), + } + } + + fn call_count(&self) -> u64 { + self.calls.load(Ordering::SeqCst) + } +} + +impl BrowserOpener for ScriptedOpener { + fn open(&self, url: &str) -> Result<(), String> { + self.calls.fetch_add(1, Ordering::SeqCst); + let query = match self.script { + Script::FailToOpen => return Err("no browser available".into()), + Script::Approve => "code=scripted-code", + Script::Deny => "error=access_denied", + }; + // Pull the loopback redirect target and the anti-CSRF state out of the + // authorize URL, then fire the callback from a separate thread so this + // synchronous `open()` returns and the flow proceeds to await it. + let parsed = url::Url::parse(url).expect("authorize URL must parse"); + let redirect = parsed + .query_pairs() + .find(|(k, _)| k == "redirect_uri") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries redirect_uri"); + let state = parsed + .query_pairs() + .find(|(k, _)| k == "state") + .map(|(_, v)| v.into_owned()) + .expect("authorize URL carries state"); + let redirect = url::Url::parse(&redirect).expect("redirect_uri must parse"); + // The coordinator's listener binds 127.0.0.1; connect there directly so + // the callback can't land on an IPv6 `localhost` (::1) with no listener. + let port = redirect.port().expect("loopback redirect carries a port"); + // `state` is base64url (no reserved characters), safe to inline. + let request = format!( + "GET /?{query}&state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: close\r\n\r\n" + ); + std::thread::spawn(move || { + // A real browser holds the connection open until the callback page + // responds; do the same so hyper dispatches the request before the + // socket closes (a bare write+drop races the server and is lost). + if let Ok(mut sock) = TcpStream::connect(("127.0.0.1", port)) { + use std::io::Read; + let _ = sock.write_all(request.as_bytes()); + let _ = sock.flush(); + let mut discard = Vec::new(); + let _ = sock.read_to_end(&mut discard); + } + }); + Ok(()) + } +} + +// ---- stub OIDC provider -------------------------------------------------- + +#[derive(Deserialize)] +struct TokenForm { + grant_type: String, +} + +struct Stub { + base: String, + /// authorization-code exchanges served (browser flows completed). + code_grants: Arc, + /// refresh-token grants served. + refresh_grants: Arc, +} + +/// How the stub's token endpoint answers a `refresh_token` grant. Lets a test +/// distinguish the three ways a refresh can fail so it can assert the +/// coordinator classifies each correctly: a `401` is a real credential +/// rejection (dead refresh token), a `500` is a transient provider fault, and +/// a hang models a slow/unreachable provider that must trip the per-request +/// HTTP timeout. Authorization-code grants are never affected. +#[derive(Clone, Copy)] +enum RefreshMode { + /// `200` with a fresh access token. + Succeed, + /// `401 invalid_grant` — the grant itself is rejected. + Reject, + /// `500` — a provider-side fault, transient rather than a credential + /// decision. + /// + /// Used only by Unix-only tests (refresh-error classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + ServerError, + /// A 4xx with the given OAuth `error` code in the body. Lets a test assert + /// the coordinator treats `invalid_grant` (any 4xx) as a dead grant, but + /// every other error code — and any non-`invalid_grant` status like `429` + /// — as infrastructural rather than a credential rejection. + /// + /// Used only by Unix-only tests (refresh-error classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + ClientError(axum::http::StatusCode, &'static str), + /// Sleep `d` before answering, so the caller's per-request HTTP timeout + /// elapses first (a transport timeout, not a verdict from the provider). + /// + /// Used only by Unix-only tests (refresh-timeout classification). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + Hang(Duration), + /// `200` returning the same fixed access token on every grant, regardless + /// of how many are served. Models a provider that re-issues an identical + /// access token, so a bounded rerun can hand back the exact bytes the + /// caller already reported 401-rejected. + /// + /// Used only by Unix-only tests (rejected-token neutralization, sticky + /// reissuance). Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + SucceedSticky(&'static str), +} + +/// How the stub's token endpoint answers an `authorization_code` grant (the +/// browser code exchange). Lets a test drive the exchange classifier: a +/// `401 invalid_grant` is a genuine rejected code (`ExchangeFailed`), while a +/// `429`, a `500`, and a malformed `200` are transient/provider faults that +/// must classify as `NetworkUnavailable` rather than poisoning the cooldown. +#[derive(Clone, Copy)] +enum ExchangeMode { + /// `200` with a fresh access token — the browser flow completes. + Succeed, + /// A failing status carrying the given OAuth `error` body. Only a 4xx + /// `invalid_grant` is a true code rejection; every other status/error is + /// infrastructural. + Fail(axum::http::StatusCode, &'static str), + /// `200` whose body lacks an `access_token` — a malformed success the + /// provider should never send, so it is a fault, not a rejected code. + MalformedSuccess, + /// Sleep `d` before answering, so the caller's per-request HTTP timeout + /// elapses first (a transport timeout, not a verdict from the provider). + Hang(Duration), + /// `200` returning the same fixed access token on every authorization-code + /// exchange. Models a provider that re-issues an identical access token, so + /// a browser sign-in (reached after a dead refresh) can hand back the exact + /// bytes the caller reported 401-rejected. + /// + /// Used only by Unix-only tests (sticky browser exchange after dead refresh). + /// Gated to suppress dead-code warnings on Windows. + #[cfg(unix)] + SucceedSticky(&'static str), +} + +/// Boot a stub provider. `reject_refresh` makes the token endpoint 401 every +/// refresh-token grant (a dead refresh token); authorization-code grants +/// always succeed with a fresh token. +async fn spawn_stub(reject_refresh: bool) -> Stub { + spawn_stub_with(if reject_refresh { + RefreshMode::Reject + } else { + RefreshMode::Succeed + }) + .await +} + +/// Boot a stub provider whose refresh-token grant follows `mode`. Discovery and +/// authorization-code grants always succeed instantly regardless of `mode`. +async fn spawn_stub_with(mode: RefreshMode) -> Stub { + spawn_stub_with_modes(mode, ExchangeMode::Succeed).await +} + +/// Boot a stub whose authorization-code exchange follows `exchange`. Refresh +/// grants succeed; used by the exchange-classifier tests. +async fn spawn_stub_with_exchange(exchange: ExchangeMode) -> Stub { + spawn_stub_with_modes(RefreshMode::Succeed, exchange).await +} + +/// Boot a stub provider whose refresh-token grant follows `refresh` and whose +/// authorization-code grant follows `exchange`. Discovery always succeeds. +async fn spawn_stub_with_modes(refresh: RefreshMode, exchange: ExchangeMode) -> Stub { + let code_grants = Arc::new(AtomicU64::new(0)); + let refresh_grants = Arc::new(AtomicU64::new(0)); + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let disco_base = base.clone(); + + let discovery = move || { + let base = disco_base.clone(); + async move { + Json(json!({ + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + })) + } + }; + + let code_for_token = code_grants.clone(); + let refresh_for_token = refresh_grants.clone(); + let app = Router::new() + // Two discovery paths so distinct-host tests derive distinct cache + // keys (the key hashes the discovery URL) from one stub. + .route("/disco/a", get(discovery.clone())) + .route("/disco/b", get(discovery)) + .route( + "/token", + post(move |Form(form): Form| { + let code_grants = code_for_token.clone(); + let refresh_grants = refresh_for_token.clone(); + let refresh = refresh; + let exchange = exchange; + async move { + if form.grant_type == "refresh_token" { + let n = refresh_grants.fetch_add(1, Ordering::SeqCst) + 1; + // A hang delays the answer so the caller's per-request + // HTTP timeout can elapse first (transport timeout, not + // a credential decision). + #[cfg(unix)] + if let RefreshMode::Hang(d) = refresh { + tokio::time::sleep(d).await; + } + return match refresh { + RefreshMode::Reject => ( + axum::http::StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_grant" })), + ), + #[cfg(unix)] + RefreshMode::ServerError => ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "temporarily_unavailable" })), + ), + #[cfg(unix)] + RefreshMode::ClientError(status, error) => { + (status, Json(json!({ "error": error }))) + } + RefreshMode::Succeed => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("refreshed-token-{n}"), + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + #[cfg(unix)] + RefreshMode::Hang(_) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("refreshed-token-{n}"), + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + #[cfg(unix)] + RefreshMode::SucceedSticky(tok) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": tok, + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ), + }; + } + let n = code_grants.fetch_add(1, Ordering::SeqCst) + 1; + // A hang delays the answer so the caller's per-request HTTP + // timeout can elapse first (transport timeout, not a code + // decision), mirroring the refresh path above. + if let ExchangeMode::Hang(d) = exchange { + tokio::time::sleep(d).await; + } + match exchange { + ExchangeMode::Succeed => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + ExchangeMode::Fail(status, error) => { + (status, Json(json!({ "error": error }))) + } + ExchangeMode::MalformedSuccess => ( + axum::http::StatusCode::OK, + Json(json!({ "token_type": "bearer" })), + ), + #[cfg(unix)] + ExchangeMode::SucceedSticky(tok) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": tok, + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + // Reached only after the sleep above; answer as a + // success the caller has already abandoned. + ExchangeMode::Hang(_) => ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ), + } + } + }), + ); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + Stub { + base, + code_grants, + refresh_grants, + } +} + +/// Control handle for a stub whose refresh response is held until the parent +/// explicitly releases it. Used by the cross-process digest test to establish +/// deterministic ordering: the parent waits for `request_received` (proves A +/// holds the lock and is mid-refresh), then spawns B, waits for B's snapshot +/// marker, and finally calls `release()` before joining both workers. +#[cfg(unix)] +struct RefreshGate { + /// Notified by the stub once it has received the first refresh request. + request_received: Arc, + /// Parent signals this to let the stub return the response. + proceed: Arc, +} + +#[cfg(unix)] +impl RefreshGate { + /// Asynchronously wait until the stub has received A's refresh request. + async fn wait_for_request(&self) { + self.request_received.notified().await; + } + + /// Release the held refresh response so the stub replies to A. + fn release(&self) { + self.proceed.notify_one(); + } +} + +/// Shape of the refresh response returned by [`spawn_stub_with_held_refresh`]. +/// +/// - `Sticky(tok)` — every refresh returns `200 OK` with `access_token: tok`. +/// - `Reject` — every refresh returns `401 Unauthorized` with `invalid_grant`. +#[cfg(unix)] +enum HeldRefreshResponse { + Sticky(&'static str), + Reject, +} + +/// Spawn a stub that holds the FIRST refresh request until the parent calls +/// [`RefreshGate::release()`], then replies according to `response`. +/// Subsequent refresh requests skip the gate and reply immediately with the +/// same shape. Code-grant (`authorization_code`) requests are always answered +/// immediately with a fresh browser token. +/// +/// Returns the stub (for `refresh_grants` / `code_grants` assertions) and the +/// control gate. Used by the cross-process held-refresh tests. +#[cfg(unix)] +async fn spawn_stub_with_held_refresh(response: HeldRefreshResponse) -> (Stub, RefreshGate) { + let code_grants = Arc::new(AtomicU64::new(0)); + let refresh_grants = Arc::new(AtomicU64::new(0)); + let request_received = Arc::new(tokio::sync::Notify::new()); + let proceed = Arc::new(tokio::sync::Notify::new()); + + let listener = tokio::net::TcpListener::bind(SocketAddr::from(([127, 0, 0, 1], 0))) + .await + .unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let disco_base = base.clone(); + + let discovery = move || { + let base = disco_base.clone(); + async move { + Json(json!({ + "authorization_endpoint": format!("{base}/authorize"), + "token_endpoint": format!("{base}/token"), + })) + } + }; + + let code_for_token = code_grants.clone(); + let refresh_for_token = refresh_grants.clone(); + let received_for_handler = request_received.clone(); + let proceed_for_handler = proceed.clone(); + // Track whether the first refresh has been released yet. Once the first + // grant is released, subsequent grants return immediately. + let first_released = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let reject = matches!(response, HeldRefreshResponse::Reject); + let sticky_tok = match response { + HeldRefreshResponse::Sticky(tok) => tok, + HeldRefreshResponse::Reject => "", + }; + + let app = Router::new() + .route("/disco/a", get(discovery.clone())) + .route("/disco/b", get(discovery)) + .route( + "/token", + post(move |Form(form): Form| { + let code_grants = code_for_token.clone(); + let refresh_grants = refresh_for_token.clone(); + let received = received_for_handler.clone(); + let proceed = proceed_for_handler.clone(); + let first_released = first_released.clone(); + async move { + if form.grant_type == "refresh_token" { + refresh_grants.fetch_add(1, Ordering::SeqCst); + // Hold only the first refresh request; once released, + // all subsequent requests return immediately. + if !first_released.swap(true, Ordering::SeqCst) { + received.notify_one(); + proceed.notified().await; + } + return if reject { + ( + axum::http::StatusCode::UNAUTHORIZED, + Json(json!({ "error": "invalid_grant" })), + ) + } else { + ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": sticky_tok, + "refresh_token": "rotated-refresh", + "expires_in": 3600, + })), + ) + }; + } + let n = code_grants.fetch_add(1, Ordering::SeqCst) + 1; + ( + axum::http::StatusCode::OK, + Json(json!({ + "access_token": format!("browser-token-{n}"), + "refresh_token": "browser-refresh", + "expires_in": 3600, + })), + ) + } + }), + ); + + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + let stub = Stub { + base, + code_grants, + refresh_grants, + }; + let gate = RefreshGate { + request_received, + proceed, + }; + (stub, gate) +} + +fn config(stub: &Stub, disco_path: &str, cache_dir: &std::path::Path) -> PkceOAuthConfig { + PkceOAuthConfig { + discovery_url: format!("{}{disco_path}", stub.base), + client_id: "test-client".into(), + scopes: vec!["offline_access".into()], + cache_namespace: "databricks".into(), + cache_dir_override: Some(cache_dir.to_path_buf()), + } +} + +fn future_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() + + 3600 +} + +fn cache_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + use sha2::Digest; + let mut h = sha2::Sha256::new(); + h.update(cfg.discovery_url.as_bytes()); + h.update(b"|"); + h.update(cfg.client_id.as_bytes()); + h.update(b"|"); + h.update(cfg.scopes.join(",").as_bytes()); + let hash = hex::encode(h.finalize()); + cache_dir + .join(&cfg.cache_namespace) + .join(format!("{hash}.json")) +} + +/// The cross-process attempt sidecar path for a config, matching the +/// coordinator's `append_ext(cache_path, "attempt")`. Used by tests that +/// inspect the generation counter directly after a cross-process adoption to +/// verify the adopter did not re-write a new generation. +fn attempt_sidecar_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + let mut p = cache_file_path(cfg, cache_dir).into_os_string(); + p.push(".attempt"); + p.into() +} + +/// The cross-process advisory lock path for a config, matching the +/// coordinator's `append_ext(cache_path, "lock")`. Used to point the +/// out-of-process lock-holder helper at the exact file the coordinator +/// contends on. +#[cfg(unix)] +fn lock_file_path(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> std::path::PathBuf { + let mut p = cache_file_path(cfg, cache_dir).into_os_string(); + p.push(".lock"); + p.into() +} + +fn seed_cache(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path, body: serde_json::Value) { + let path = cache_file_path(cfg, cache_dir); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, serde_json::to_vec(&body).unwrap()).unwrap(); +} + +// ---- acceptance matrix --------------------------------------------------- + +#[tokio::test] +async fn test_same_key_concurrent_callers_share_one_browser_attempt() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + + // Two independent sources on the same key in ONE process. The in-memory + // INFLIGHT registry coalesces them before the file lock, so this proves the + // in-process single-flight — one leader runs the browser flow, the other + // joins its published result. The genuine cross-process race is + // `test_crossprocess_two_coordinators_race_to_one_grant_and_cache`. + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Auto, None), + b.acquire_with_intent(AuthIntent::Auto, None), + ); + let ta = ra.expect("first caller authenticates"); + let tb = rb.expect("second caller authenticates"); + + // One browser launch, one code exchange, one shared token. + assert_eq!( + opener.call_count(), + 1, + "only one browser attempt for one key" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange" + ); + assert_eq!(ta, tb, "both callers observe the same token"); + assert_eq!(ta, "browser-token-1"); +} + +#[tokio::test] +async fn test_denied_then_auto_reads_cooldown_without_second_launch() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Deny); + + let src = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let first = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + first, + Err(AuthError::Denied), + "first Auto attempt is denied" + ); + assert_eq!(opener.call_count(), 1); + + // The denial wrote a cooldown; a subsequent Auto caller reads it and + // returns the recorded outcome instead of popping a second browser. + let second = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + second, + Err(AuthError::Denied), + "queued Auto caller honors the cooldown" + ); + assert_eq!( + opener.call_count(), + 1, + "cooldown suppresses the second browser launch" + ); +} + +#[tokio::test] +async fn test_userinitiated_retry_bypasses_cooldown_and_reopens() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // First attempt: denied, writes a cooldown. + let deny_opener = ScriptedOpener::new(Script::Deny); + let denier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny_opener.clone()), + ) + .unwrap(); + assert_eq!( + denier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await, + Err(AuthError::Denied) + ); + + // The user explicitly retries: UserInitiated bypasses (and clears) the + // cooldown and opens a fresh browser, which now succeeds. + let approve_opener = ScriptedOpener::new(Script::Approve); + let retrier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = retrier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("explicit retry re-launches the browser and succeeds"); + assert_eq!(token, "browser-token-1"); + assert_eq!( + approve_opener.call_count(), + 1, + "UserInitiated retry launches despite the prior cooldown" + ); + + // Cooldown cleared on success: a follow-up Auto now sees a valid token, + // never the stale denial. + let auto = retrier.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!(auto, Ok("browser-token-1".to_string())); +} + +#[tokio::test] +async fn test_distinct_hosts_do_not_inherit_cooldown() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // Host A is denied and records a cooldown under key A. + let deny_opener = ScriptedOpener::new(Script::Deny); + let host_a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny_opener.clone()), + ) + .unwrap(); + assert_eq!( + host_a.acquire_with_intent(AuthIntent::Auto, None).await, + Err(AuthError::Denied) + ); + + // Host B is a different key (different discovery URL). It must NOT inherit + // A's cooldown: an Auto caller launches its own browser and succeeds. + let approve_opener = ScriptedOpener::new(Script::Approve); + let host_b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/b", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = host_b + .acquire_with_intent(AuthIntent::Auto, None) + .await + .expect("distinct host is unaffected by another key's cooldown"); + assert_eq!(token, "browser-token-1"); + assert_eq!(approve_opener.call_count(), 1); +} + +#[tokio::test] +async fn test_browser_open_failure_is_typed_and_retryable_by_user() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + + // Every launch strategy fails: the flow reports the typed BrowserOpenFailed + // without waiting on a listener nobody will reach. + let fail_opener = ScriptedOpener::new(Script::FailToOpen); + let failing = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(fail_opener.clone()), + ) + .unwrap(); + let result = failing + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::BrowserOpenFailed), + "a failed launch surfaces as the typed BrowserOpenFailed" + ); + assert_eq!(fail_opener.call_count(), 1); + + // A failed launch writes a cooldown, but a UserInitiated retry bypasses it + // and reopens — a transient "no browser" (e.g. race with a display coming + // up) must never wedge an explicit user sign-in. + let approve_opener = ScriptedOpener::new(Script::Approve); + let retrier = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve_opener.clone()), + ) + .unwrap(); + let token = retrier + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("explicit retry reopens despite the prior launch failure"); + assert_eq!(token, "browser-token-1"); + assert_eq!(approve_opener.call_count(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_dead_refresh_returns_refresh_rejected_without_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token WITH a refresh token, but the server rejects the refresh + // grant (dead/rotated). A Headless caller must classify this terminally as + // RefreshRejected and never open a browser. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "Headless dead-refresh is terminal RefreshRejected" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the refresh grant was attempted exactly once" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_dead_refresh_converts_to_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Same dead-refresh seed, but an interactive intent must fall through to a + // browser flow instead of failing terminally. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await + .expect("interactive intent recovers via the browser"); + assert_eq!(token, "browser-token-1"); + assert_eq!(opener.call_count(), 1, "interactive intent opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_expired_token_live_refresh_recovers_silently() { + let stub = spawn_stub(false).await; // refresh succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("live refresh recovers a Headless caller silently"); + assert_eq!(token, "refreshed-token-1"); + assert_eq!(opener.call_count(), 0, "no browser on a live refresh"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_login_reuses_valid_cache_without_browser() { + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A still-valid cached token short-circuits interactive_login: an explicit + // sign-in should not re-prompt when a good token is already present. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "already-valid", + "refresh_token": "rt", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + src.interactive_login() + .await + .expect("interactive_login succeeds off the valid cache"); + assert_eq!( + opener.call_count(), + 0, + "a valid cached token means no browser prompt" + ); +} + +// ---- locally-fresh rejected bearer (401) recovery ------------------------ +// +// The saved-model picker's recovery path: model discovery 401s a bearer that +// still looks locally fresh (its `expires_at` is in the future) and whose +// refresh grant is dead. Passing that exact token as `rejected` makes the +// clock untrustworthy, so the acquisition must not short-circuit on the fresh +// cache. `Auto` and `UserInitiated` then convert to a browser; `Headless` +// stays terminal with `RefreshRejected`. Seeding a *future*-expiry token is +// what distinguishes this from the expired-token refresh path. + +/// Seed a not-yet-expired access token with a (dead) refresh token and return +/// the access token so the caller can pass it as `rejected`. +#[cfg(unix)] +fn seed_fresh_rejectable(cfg: &PkceOAuthConfig, cache_dir: &std::path::Path) -> String { + let access = "fresh-but-rejected"; + seed_cache( + cfg, + cache_dir, + json!({ + "access_token": access, + "refresh_token": "dead-refresh", + "expires_at": future_secs(), + }), + ); + access.to_string() +} + +#[cfg(unix)] +#[tokio::test] +async fn test_auto_rejected_fresh_bearer_with_dead_refresh_launches_browser() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let rejected = seed_fresh_rejectable(&cfg, cache.path()); + + // The token is locally fresh, so without `rejected` it would be a cache + // hit and never reach the browser. Passing it as rejected forces the + // clock-based hit to fail, the dead refresh to be attempted, and an Auto + // caller to fall through to the browser. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Auto, Some(&rejected)) + .await + .expect("Auto recovers a rejected-but-fresh bearer via the browser"); + assert_eq!(token, "browser-token-1"); + assert_eq!(opener.call_count(), 1, "Auto launches a browser to recover"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_headless_rejected_fresh_bearer_with_dead_refresh_returns_refresh_rejected() { + let stub = spawn_stub(true).await; // refresh grants 401 + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let rejected = seed_fresh_rejectable(&cfg, cache.path()); + + // Same locally-fresh rejected seed, but a Headless caller cannot open a + // browser: a dead refresh is terminal RefreshRejected, never a launch. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::Headless, Some(&rejected)) + .await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "Headless dead-refresh on a rejected fresh bearer is terminal" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +// ---- refresh transport failures are not credential rejections ------------ +// +// A refresh that never gets a verdict from the token endpoint — a per-request +// timeout, or a 5xx — is infrastructural, not a dead credential. It must +// surface as `NetworkUnavailable` and never pop a browser or return +// `RefreshRejected`, which would misreport a transient fault as a rotated +// token and (for interactive intents) prompt a needless sign-in. + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_timeout_is_network_unavailable_not_rejected() { + // The token endpoint hangs far longer than the injected per-request HTTP + // timeout, so the refresh call times out at the transport layer with no + // verdict from the provider. A short real-time timeout is injected rather + // than pausing the clock: under `start_paused` tokio auto-advances into + // the timer while the real loopback discovery GET is still in flight, so + // discovery — not the refresh — would trip the timeout, and the refresh + // would never even be attempted. Real time keeps the timeout attached to + // the request that actually hangs, which the `refresh_grants == 1` guard + // below proves. + let stub = spawn_stub_with(RefreshMode::Hang(Duration::from_secs(30))).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token with a refresh token: the coordinator attempts the refresh, + // which hangs past the HTTP timeout. A Headless caller must classify the + // timeout as NetworkUnavailable, not RefreshRejected. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "slow-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with_http_timeout( + cfg, + Arc::new(opener.clone()), + Duration::from_millis(300), + ) + .unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a refresh transport timeout is infrastructural, not a rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a timed-out refresh never becomes a credential decision" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the refresh was attempted exactly once before timing out" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_server_error_is_network_unavailable_not_rejected() { + let stub = spawn_stub_with(RefreshMode::ServerError).await; // refresh 500s + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A 5xx is a provider-side fault, not a grant rejection: an interactive + // intent must NOT pop a browser off it, and it must surface as + // NetworkUnavailable rather than RefreshRejected. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "server-error-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a refresh 5xx is transient, not a credential rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a provider 5xx must not trigger an interactive browser fallback" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +// ---- 4xx classification: only `invalid_grant` is a dead refresh token ----- +// +// RFC 6749 §5.2 uses 400/401 token responses for several `error` codes, but +// only `invalid_grant` means the refresh token is dead. Every other 4xx — +// `invalid_request`, `invalid_client`, `unsupported_grant_type`, +// `invalid_scope`, `408`, `429` — is a request/config/transient fault a +// browser cannot repair, so it must stay infrastructural (`NetworkUnavailable`) +// and never pop a browser. The classifier keys on the OAuth error body, not +// the bare status class. + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_400_invalid_grant_is_dead_grant_not_network() { + // A 400 (not just 401) carrying `invalid_grant` is still a dead refresh + // token, so a Headless caller must classify it terminally as + // RefreshRejected — proving the decision is the body error, not the status. + let stub = spawn_stub_with(RefreshMode::ClientError( + axum::http::StatusCode::BAD_REQUEST, + "invalid_grant", + )) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "a 400 invalid_grant is a dead refresh token, not infrastructural" + ); + assert_eq!(opener.call_count(), 0, "Headless never opens a browser"); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_refresh_non_invalid_grant_4xx_is_network_unavailable_not_rejected() { + // Every 4xx whose OAuth body is NOT `invalid_grant` is a request/config or + // transient fault a browser cannot repair, so it must surface as + // NetworkUnavailable and never pop a browser — even for an interactive + // intent that COULD. Two representative cases prove the classifier keys on + // the body `error`, not the status class: a 400 `invalid_request` + // (malformed/misconfigured) and a 429 `slow_down` (transient rate limit). + for (status, error, refresh_token) in [ + ( + axum::http::StatusCode::BAD_REQUEST, + "invalid_request", + "misconfigured-refresh", + ), + ( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "slow_down", + "rate-limited-refresh", + ), + ] { + let stub = spawn_stub_with(RefreshMode::ClientError(status, error)).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": refresh_token, + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, None) + .await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a non-invalid_grant 4xx ({status} {error}) is infrastructural, not a credential rejection" + ); + assert_eq!( + opener.call_count(), + 0, + "a browser cannot repair {error}, so none is opened" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn test_two_concurrent_userinitiated_denials_share_one_browser() { + // Two UserInitiated callers arrive together on one key. The first is the + // leader and opens the browser; the second is a pre-existing joiner that + // must receive the leader's SAME Denied result rather than acquire the + // lock afterward, clear the cooldown, and pop a second browser. This is + // the failure-sharing that a lock-alone protocol loses. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Deny); + + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(opener.clone()), + ) + .unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::UserInitiated, None), + b.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + assert_eq!(ra, Err(AuthError::Denied), "leader observes the denial"); + assert_eq!( + rb, + Err(AuthError::Denied), + "the joiner shares the leader's denial, not a fresh attempt" + ); + assert_eq!( + opener.call_count(), + 1, + "one browser launch shared across both concurrent UserInitiated callers" + ); +} + +// ---- mixed-intent coalescing must not leak an Auto cooldown to a user ----- +// +// `Auto` and `UserInitiated` disagree on cooldown policy: `Auto` honors a +// recorded cooldown and returns its `Denied`/`TimedOut` without a browser, +// while `UserInitiated` bypasses the cooldown and opens a fresh sign-in. If +// both coalesced onto one in-process slot, a user's explicit action arriving +// behind an `Auto` leader would inherit the leader's suppressed result and +// silently get *nothing* — no browser, no bypass. Keying the single-flight +// slot by the full intent keeps the two from sharing a slot. + +#[tokio::test] +async fn test_userinitiated_joiner_does_not_inherit_auto_cooldown_result() { + // Race an Auto caller and a UserInitiated caller on one key. `join!` polls + // the Auto future first: it becomes the in-process leader, takes the file + // lock, and opens a browser that is DENIED — and it yields on the callback + // wait while still holding the lock and its INFLIGHT slot. The + // UserInitiated caller is then polled *while the Auto attempt is in flight*. + // + // Before the fix, both intents keyed the single-flight slot by browser + // capability alone, so the UserInitiated caller joined the Auto leader's + // slot and inherited its `Denied` — never opening its own browser, never + // getting the cooldown bypass it promises. Keying by the full intent keeps + // them apart: the UserInitiated caller runs its own flow, bypasses the + // cooldown the Auto denial recorded, and signs in on its own browser. + // + // Distinct openers make the coalescing visible: if the UserInitiated caller + // had inherited the Auto result, its `approve` opener would never fire. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let deny = ScriptedOpener::new(Script::Deny); + let approve = ScriptedOpener::new(Script::Approve); + + let auto = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(deny.clone()), + ) + .unwrap(); + let user = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + + let (auto_res, user_res) = tokio::join!( + auto.acquire_with_intent(AuthIntent::Auto, None), + user.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + assert_eq!( + auto_res, + Err(AuthError::Denied), + "the Auto leader observes its own browser denial" + ); + let bearer = + user_res.expect("the UserInitiated caller runs its own sign-in, not the Auto slot"); + assert!( + bearer.starts_with("browser-token-"), + "UserInitiated got a fresh browser token, not the Auto leader's Denied: {bearer}" + ); + assert_eq!( + deny.call_count(), + 1, + "the Auto leader opened exactly one (denied) browser" + ); + assert_eq!( + approve.call_count(), + 1, + "the UserInitiated caller opened its own browser instead of inheriting the Auto denial" + ); +} + +// ---- a joiner must never inherit its own rejected token ------------------- +// +// The in-process slot is keyed by (lock path, intent) only, so a 401-recovery +// joiner shares a leader that ran with a *different* `rejected` value. If the +// leader publishes a token equal to THIS caller's rejected bytes — e.g. its +// refresh produced exactly the generation the joiner just reported 401 — the +// joiner would retry the provider with the credentials it already knows are +// dead. The joiner must instead detect the collision and run its own bounded +// acquisition, obtaining a token that differs from its `rejected`. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_never_receives_its_own_rejected_token() { + // Two concurrent `Headless` 401-recovery callers on one key, each rejecting + // a DIFFERENT bearer. The seeded cache token is expired, so neither caller + // is satisfied by the fast path (or the under-lock re-read) — both must go + // to the live refresh grant, which is what makes the leader slow enough to + // join. `join!` polls A first: it registers the INFLIGHT slot as leader, + // takes the file lock, and yields on its refresh HTTP call while holding + // the slot. B is then polled *while A is in flight* and joins A's slot. + // + // A's refresh yields `refreshed-token-1` and saves it. That is exactly the + // bearer B passed as `rejected` (B held gen-1 and was 401'd on it). Before + // the fix, B — a joiner keyed only by intent — received A's published + // `refreshed-token-1`: the precise bytes it just reported rejected. The fix + // makes B detect `published == own rejected`, fall through to its own + // acquisition, and refresh again to `refreshed-token-2`. The rerun goes + // straight to the leader body (not back through the registry), and its + // under-lock re-read rejects A's freshly-saved gen-1 (it equals B's + // `rejected`), so B can neither re-join the dead generation's slot, adopt + // its own rejected bytes from disk, nor loop. + let stub = spawn_stub(false).await; // refresh always succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired access token with a live refresh token: the expiry forces both + // callers past the cache into the refresh grant regardless of their + // distinct `rejected` values. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("rejected-by-a")), + b.acquire_with_intent(AuthIntent::Headless, Some("refreshed-token-1")), + ); + + assert_eq!( + ra, + Ok("refreshed-token-1".to_string()), + "the leader refreshes to gen-1, which differs from its own rejected value" + ); + let b_token = rb.expect("the joiner runs its own acquisition instead of inheriting gen-1"); + assert_ne!( + b_token, "refreshed-token-1", + "the joiner must never receive the exact bytes it reported 401-rejected" + ); + assert_eq!( + b_token, "refreshed-token-2", + "the joiner refreshed once more to a token that differs from its rejected value" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "exactly two refreshes: the leader's, then the joiner's single bounded rerun — no loop" + ); + assert_eq!( + opener.call_count(), + 0, + "a live refresh recovers both callers without any browser" + ); +} + +// ---- a bounded rerun that re-issues the rejected bytes must fail typed ----- +// +// The joiner-collision fix reruns its own bounded acquisition when the leader +// publishes the joiner's own rejected token. That rerun is only safe if it, +// too, refuses to hand back the rejected bytes: a provider that re-issues an +// identical access token on refresh would otherwise let the exact 401'd +// credential escape through the rerun. The coordinator guards the refresh +// success at the persistence boundary (`finish`), so both a plain leader and +// this rerun terminate with a typed auth error before caching the rejected +// token rather than returning it. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_rerun_reissuing_rejected_token_fails_typed_not_loop() { + // A sticky provider returns ONE fixed access token on every refresh. Leader + // A rejects a different value, so its refresh to the sticky token is a + // clean success it publishes and caches. Joiner B rejected exactly the + // sticky token: it collides with A's published result, reruns its own + // bounded acquisition, and that rerun's refresh hands back the sticky token + // again — B's own rejected bytes. The persistence-boundary guard turns that + // into a terminal `RefreshRejected` (Headless, no browser) instead of + // returning the dead credential or looping. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("sticky-token")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("rejected-by-a")), + b.acquire_with_intent(AuthIntent::Headless, Some("sticky-token")), + ); + + assert_eq!( + ra, + Ok("sticky-token".to_string()), + "the leader's refresh yields the sticky token, which differs from its own rejected value" + ); + assert_eq!( + rb, + Err(AuthError::RefreshRejected), + "the joiner's rerun re-issued its own rejected bytes and must fail typed, not return them" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "exactly two refreshes: the leader's, then the joiner's single bounded rerun — no loop" + ); + assert_eq!( + opener.call_count(), + 0, + "a headless collision never opens a browser" + ); +} + +// ---- a joiner with a DIFFERENT rejected must not inherit a rejection-relative failure --- +// +// When a leader A rejects token X (its own `rejected`) and the refresh yields +// X again — causing `finish()` to return `RefreshRejected` — that failure is +// scoped to A's specific rejected token. A joiner B waiting on the same slot +// with a *different* rejected token Y must NOT adopt that failure: the refresh +// grant of X is a perfectly valid token for B (B only rejected Y). The slot +// publishes A's rejected-token digest; B detects the mismatch and reruns its +// own `acquire_leader` — which finds X already in the cache from A's successful +// write (X was issued but not cached because A had it as `rejected`, but in +// Carl's scenario there was NO prior good token — the refresh just minted X +// which IS good for B), and returns it. +// +// Concrete scenario: A rejected X, refresh re-issues X → A gets RefreshRejected. +// B rejected Y (different), refresh would yield X for B → B succeeds. + +#[cfg(unix)] +#[tokio::test] +async fn test_joiner_with_different_rejected_does_not_inherit_leaders_rejection_failure() { + // Sticky provider always returns "X" on every refresh grant. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("X")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + // A rejected "X" (same as what the provider always issues). The refresh + // re-issues "X", `finish()` returns RefreshRejected — the failure is + // rejection-relative to A's own rejected bytes. + // + // B rejected "Y" (different). It should NOT inherit A's RefreshRejected: + // the provider can give B "X", which is valid for B. + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("X")), + b.acquire_with_intent(AuthIntent::Headless, Some("Y")), + ); + + assert_eq!( + ra, + Err(AuthError::RefreshRejected), + "A's refresh re-issued its own rejected token X — typed failure for A" + ); + assert_eq!( + rb, + Ok("X".to_string()), + "B's rejected was Y (not X), so B reruns and its refresh yields X — a valid token for B" + ); + assert_eq!( + opener.call_count(), + 0, + "headless callers never open a browser" + ); + // At least two refresh grants: A's, then B's rerun. + assert!( + stub.refresh_grants.load(Ordering::SeqCst) >= 2, + "B must have run its own refresh (rerun, not adoption)" + ); +} + +// ---- in-process joiner state reconciliation (P1 regressions) --------------- +// +// These tests drive two independently constructed same-key sources through real +// leader/joiner acquisition and verify that subsequent public reads on both +// sources reflect the shared outcome — not the stale or absent credential each +// source carried before joining. +// +// The coordinator's in-process single-flight coalesces callers on a shared +// `InflightSlot`. On the old bearer-only publication path the joiner's own +// `state` cell was never updated, so: +// - success: B's next plain `bearer()` served the locally-fresh-but-rejected +// token X rather than the just-acquired Y (memory won over disk). +// - failure: B's matching rejected X remained live; its next `bearer()` still +// served it. +// - no-persistence (Windows): B's state stayed empty; its next headless read +// returned `NoCredential` instead of Y and a second browser opened. +// +// All three tests exercise the full `finish()` → `acquire_locked()` → +// `acquire_leader()` → `LeaderGuard::complete()` → joiner wiring. + +// Unix-specific: the seed provides a live refresh token. The non-Unix constructor +// does not read the disk cache, so without a seed in memory A's headless path +// returns NoCredential rather than RefreshRejected. +#[cfg(unix)] +#[tokio::test] +async fn test_inprocess_joiner_reconciles_stale_state_after_shared_success() { + // Scenario: A and B both loaded a locally-fresh-but-401'd token X. A leads, + // refreshes to Y. B joins and wakes to Ok(Y). Without reconciliation B's + // state still holds unexpired X, so B's next plain bearer() serves X — the + // exact token the caller just reported 401-rejected. + // + // `join!` polls A first: A registers the INFLIGHT slot as leader, takes the + // file lock, and yields on the refresh HTTP call. B is polled while A is in + // flight, finds the slot, and joins. + // + // Mutation check (no state reconciliation): B.state stays Some(unexpired-X). + // The subsequent bearer() call on B hits the memory cache (X is not expired, + // rejected=None so identity check passes), and `a_next == b_next` FAILS + // because ra_next = Y and rb_next = X. + let stub = spawn_stub(false).await; // refresh returns fresh token + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::FailToOpen); // headless — no browser + let cfg = config(&stub, "/disco/a", cache.path()); + + // Unexpired X with a live refresh token: both A and B load it as their + // initial state via the constructor's `read_cache` call. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale-X", + "refresh_token": "live-refresh", + "expires_at": future_secs(), // NOT expired — locally fresh + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // Both 401-recovery callers on the same key. A becomes leader (polled + // first), refreshes to "refreshed-token-1", B joins A's slot. + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + b.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + ); + + assert_eq!( + ra, + Ok("refreshed-token-1".to_string()), + "leader (A) receives the refreshed token" + ); + assert_eq!( + rb, + Ok("refreshed-token-1".to_string()), + "joiner (B) receives the leader's token" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh — B joined A's slot rather than running its own" + ); + + // After the join, both sources must hold the new token in state. Subsequent + // plain bearer() calls (rejected=None) on both must return Y, not stale X. + let ra_next = a + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("A subsequent read must return the refreshed token"); + let rb_next = b + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("B subsequent read must return the refreshed token, not stale X"); + + assert_eq!(ra_next, "refreshed-token-1", "A subsequent read returns Y"); + assert_eq!( + rb_next, "refreshed-token-1", + "B subsequent read returns Y, not stale X — \ + mutation check: fails if joiner state was not reconciled (bearer-only publication)" + ); + // No second refresh: both subsequent reads hit the in-memory cache. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "subsequent reads hit the in-memory cache — no second network call" + ); +} + +// Unix-specific: refresh token is required for a headless rejection path. +#[cfg(unix)] +#[tokio::test] +async fn test_inprocess_joiner_neutralizes_rejected_on_matching_shared_failure() { + // Scenario: A and B both carry unexpired X as their rejected token. A leads, + // attempts a refresh, gets 401 (RefreshRejected). B joins and wakes to the + // shared failure. Without reconciliation B's state still holds unexpired X, + // so B's next plain bearer() serves it — the rejected credential reappears. + // + // With reconciliation, expire_rejected is called under lock, so X is + // force-expired in B's state and cannot be served again. + // + // Mutation check (no expire_rejected call on the joiner Err path): B.state + // still holds unexpired X after the join. B's next bearer() (rejected=None) + // hits the memory cache and returns X. The assertion `rb_next != Ok("stale-X")` + // FAILS — the rejected credential reappears. + let stub = spawn_stub(true).await; // reject_refresh=true → 401 on every refresh + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::FailToOpen); // headless — no browser + let cfg = config(&stub, "/disco/a", cache.path()); + + // Unexpired X with a live (but destined-to-be-rejected) refresh token. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale-X", + "refresh_token": "live-refresh", + "expires_at": future_secs(), // NOT expired — locally fresh + }), + ); + + let a = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let b = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + b.acquire_with_intent(AuthIntent::Headless, Some("stale-X")), + ); + + assert_eq!( + ra, + Err(AuthError::RefreshRejected), + "leader (A) gets RefreshRejected — dead refresh" + ); + assert_eq!( + rb, + Err(AuthError::RefreshRejected), + "joiner (B) shares the leader's RefreshRejected failure" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh attempt — B joined the failure rather than retrying" + ); + + // After the shared failure, B must not be able to serve stale X on a + // subsequent plain bearer() call. Without reconciliation, B.state still + // holds unexpired X and the next bearer() would return it. + let rb_next = b.acquire_with_intent(AuthIntent::Headless, None).await; + assert_ne!( + rb_next, + Ok("stale-X".to_string()), + "B must not serve the rejected token after adopting a matching shared failure — \ + mutation check: fails if the joiner Err path skips expire_rejected" + ); +} + +// Non-Unix-specific: disk persistence is disabled on Windows, so the only way +// for B to retain Y after joining is in-memory state reconciliation. On Unix +// the disk can provide Y as a fallback, masking a reconciliation failure. +#[cfg(not(unix))] +#[tokio::test] +async fn test_inprocess_joiner_populates_empty_state_no_second_acquisition() { + // Scenario: A and B both start with empty state (no disk token on non-Unix). + // A leads, opens a browser, exchanges the code for Y. B joins A's slot and + // wakes to Ok(Y). Without reconciliation, B.state stays None. B's next + // headless acquire returns NoCredential instead of Y, and a second browser + // would open if UserInitiated. + // + // Mutation check (no state reconciliation): B.state stays None. The + // subsequent headless acquire on B returns Err(NoCredential) instead of + // Ok("browser-token-1") — the assertion FAILS. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let approve = ScriptedOpener::new(Script::Approve); + + let a = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + let b = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(approve.clone()), + ) + .unwrap(); + + // Both start with empty state — UserInitiated falls through to a browser. + let (ra, rb) = tokio::join!( + a.acquire_with_intent(AuthIntent::UserInitiated, None), + b.acquire_with_intent(AuthIntent::UserInitiated, None), + ); + + assert_eq!( + ra, + Ok("browser-token-1".to_string()), + "leader (A) gets the browser token" + ); + assert_eq!( + rb, + Ok("browser-token-1".to_string()), + "joiner (B) shares the leader's browser token" + ); + assert_eq!( + approve.call_count(), + 1, + "exactly one browser opened — B joined rather than launching its own" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange" + ); + + // B's subsequent headless acquire must return Y from in-memory state without + // a second browser. Without reconciliation, B.state is None and headless + // returns NoCredential (no disk fallback on non-Unix). + let rb_next = b + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect( + "B subsequent headless read must return Y from in-memory state, not NoCredential — \ + mutation check: fails if joiner state was not reconciled (bearer-only publication)", + ); + assert_eq!( + rb_next, "browser-token-1", + "B retains Y in memory for subsequent headless reads" + ); + // No second browser: B's subsequent read hit the in-memory cache. + assert_eq!( + approve.call_count(), + 1, + "no second browser opened — B's subsequent headless read hit the in-memory cache" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "no second code exchange" + ); +} + +// ---- a browser success that re-issues the rejected bytes must fail typed --- +// +// The 401-recovery invariant lives at `finish`'s persistence boundary, so it +// must hold on the browser-success path too — not just refresh. An +// interactive caller whose refresh is dead falls through to a browser sign-in; +// if that exchange re-issues the exact token the caller reported 401-rejected +// (a provider reusing an access token within its validity window), the guard +// must terminate typed before caching it rather than hand back the dead +// bearer. A single interactive leader exercises the path; the colliding-joiner +// rerun routes through the same boundary. + +#[cfg(unix)] +#[tokio::test] +async fn test_interactive_browser_reissuing_rejected_token_fails_typed_not_loop() { + // Refresh 401s (dead), so an interactive intent falls through to the + // browser; the exchange stickily returns one fixed token on every grant. + let stub = spawn_stub_with_modes( + RefreshMode::Reject, + ExchangeMode::SucceedSticky("sticky-browser"), + ) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired seed with a (dead) refresh token: the caller misses the cache, + // its refresh is rejected, and it browses. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + + // The caller reports the sticky browser token as its rejected bearer, so + // the browser exchange hands back exactly those bytes. + let result = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("sticky-browser")) + .await; + + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a browser success equal to the rejected bytes must fail typed, not return them" + ); + assert_eq!( + opener.call_count(), + 1, + "the interactive attempt browsed exactly once — no loop re-launching the browser" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange — the guard fails terminally instead of retrying" + ); +} + +// ---- a rejected re-issue must not poison the cache for later callers ------- +// +// The persistence-boundary guard's whole purpose: a rejected-aware acquisition +// that a provider answers with the exact 401'd bytes must not leave those bytes +// cached as fresh. Before the fix, `finish()` persisted first and the guard +// fired after, so the dead token survived on disk and in memory — the next +// plain `bearer()` (`rejected = None`) and any freshly constructed source would +// serve it straight from the cache with no re-validation. These two regressions +// prove the cache is untouched after the typed failure, on both the refresh and +// the browser re-issue paths. + +#[cfg(unix)] +#[tokio::test] +async fn test_sticky_refresh_rejection_does_not_poison_cache_for_later_callers() { + // A sticky provider re-issues `sticky-token` on every refresh. A caller that + // reports `sticky-token` as its rejected bearer gets a typed failure — and + // the rejected bytes must never reach the cache. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("sticky-token")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::Headless, Some("sticky-token")) + .await; + assert_eq!( + rejected, + Err(AuthError::RefreshRejected), + "a refresh that re-issues the rejected bytes fails typed" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // The rejected bytes were never persisted: a fresh process reading the same + // cache path finds the original expired seed, not `sticky-token`. + let on_disk = std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(); + assert!( + on_disk.contains("expired-seed") && !on_disk.contains("sticky-token"), + "the failed acquisition poisoned the on-disk cache: {on_disk}" + ); + + // A freshly constructed source over the same cache must therefore refresh + // over the network to obtain the token — it cannot serve a cached poison. + // Under the bug this was a lock-free cache hit and `refresh_grants` stayed + // at 1; the fix forces a second refresh. `Headless, None` is the plain + // `bearer()` path (rejected = None) with the typed error surfaced directly. + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = fresh + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a rejected=None caller legitimately obtains the current token"); + assert_eq!(token, "sticky-token"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the fresh source re-validated over the network — it did not serve a cached poison" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_sticky_browser_rejection_does_not_poison_cache_for_later_callers() { + // Refresh is dead, so an interactive caller browses; the exchange stickily + // re-issues `sticky-browser`. A caller reporting those bytes as rejected + // gets a typed failure, and the dead token must never reach the cache. + let stub = spawn_stub_with_modes( + RefreshMode::Reject, + ExchangeMode::SucceedSticky("sticky-browser"), + ) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("sticky-browser")) + .await; + assert_eq!( + rejected, + Err(AuthError::NetworkUnavailable), + "a browser exchange that re-issues the rejected bytes fails typed" + ); + assert_eq!(opener.call_count(), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); + + // The rejected bytes were never persisted: the on-disk cache still holds + // the expired seed, so no fresh process can restore `sticky-browser`. + let on_disk = std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(); + assert!( + on_disk.contains("expired-seed") && !on_disk.contains("sticky-browser"), + "the failed browser acquisition poisoned the on-disk cache: {on_disk}" + ); + + // A subsequent plain `bearer()` (Headless, `rejected = None`) reads that + // un-poisoned cache: the seed is expired and its refresh is dead, so it + // fails `RefreshRejected` — it never serves `sticky-browser` from cache. + // Under the bug the poisoned cache made this a hit returning the dead bytes. + let fresh_opener = ScriptedOpener::new(Script::Approve); + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(fresh_opener.clone())).unwrap(); + let later = fresh.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + later, + Err(AuthError::RefreshRejected), + "a fresh source must not serve the rejected browser token from cache" + ); + assert_eq!( + fresh_opener.call_count(), + 0, + "a headless caller never browses" + ); +} + +// ---- a 401 on a locally-fresh token neutralizes the cached copy ----------- +// +// P1: the persistence-boundary guard refuses to *save* a re-issued rejected +// token, but the ORIGINAL cached copy — the exact bytes the provider just +// 401'd — is untouched. Because `is_expired` trusts only the clock, a later +// plain `bearer()` (`rejected = None`) or a freshly constructed source would +// serve that dead token straight from cache. `expire_rejected` force-expires +// the cached copy (memory and disk) under the lock the moment a caller reports +// it rejected, so no future caller and no fresh process can serve it, while the +// refresh token — not rejected, and the engine of recovery — stays intact. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_is_neutralized_for_a_fresh_process() { + // The cached access token `A` is locally UNEXPIRED, and the provider + // stickily re-issues `A` on refresh. A caller reports `A` as rejected: the + // refresh hands back `A`, the guard fails typed without persisting it — and + // the original unexpired `A` must not survive on disk for a fresh process. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!( + rejected, + Err(AuthError::RefreshRejected), + "a refresh that re-issues the rejected bytes fails typed" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // The on-disk copy of `A` was force-expired in place: the refresh token is + // preserved, but the access token's expiry is neutralized so no clock-based + // read can serve it. Under the bug it stayed at its future expiry. + let on_disk: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(cache_file_path(&cfg, cache.path())).unwrap(), + ) + .unwrap(); + assert_eq!( + on_disk["access_token"], "A", + "the entry is kept, not deleted" + ); + assert_eq!( + on_disk["refresh_token"], "live-refresh", + "the refresh token — not rejected — survives for recovery" + ); + assert_eq!( + on_disk["expires_at"], 0, + "the rejected access token was force-expired on disk" + ); + + // A freshly constructed source reading that cache must NOT serve `A` from + // the clock: it sees the neutralized entry as expired and refreshes over + // the network. Under the bug this was a lock-free cache hit returning the + // dead `A` with `refresh_grants` frozen at 1. + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = fresh + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a rejected=None caller obtains the provider's current token"); + assert_eq!(token, "A"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the fresh source re-validated over the network — it did not serve the neutralized cache" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_is_neutralized_for_the_same_source() { + // The in-memory layer of the same neutralization: after the SAME source + // fails a 401-recovery on unexpired `A`, its next plain `bearer()` + // (`rejected = None`) must not serve `A` from the in-memory cell — it must + // re-validate. `A` is sticky, so recovery returns `A` again, but only after + // a real refresh grant (the discriminator: 1 cache hit vs. 2 grants). + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + assert_eq!( + src.acquire_with_intent(AuthIntent::Headless, Some("A")) + .await, + Err(AuthError::RefreshRejected), + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // Same source, plain bearer: the in-memory `A` was neutralized, so this is + // a miss that refreshes rather than a cache hit. Under the bug the + // unexpired in-memory `A` was served directly and `refresh_grants` stayed 1. + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("a subsequent plain bearer re-validates rather than serving the dead token"); + assert_eq!(token, "A"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "the same source re-validated in memory — it did not serve the neutralized token" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_fresh_token_neutralized_when_recovery_browses() { + // The browser variant: `A` is unexpired but its refresh token is dead, so + // an interactive 401-recovery falls through to the browser, whose exchange + // stickily re-issues `A`. The guard fails typed without persisting it, and + // the neutralized `A` must not survive for a later headless caller. + let stub = spawn_stub_with_modes(RefreshMode::Reject, ExchangeMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "dead-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + let rejected = src + .acquire_with_intent(AuthIntent::UserInitiated, Some("A")) + .await; + assert_eq!( + rejected, + Err(AuthError::NetworkUnavailable), + "a browser exchange that re-issues the rejected bytes fails typed" + ); + assert_eq!(opener.call_count(), 1); + assert_eq!(stub.code_grants.load(Ordering::SeqCst), 1); + + // The unexpired `A` was force-expired on disk, so a fresh headless source + // finds it unusable and — its refresh being dead — fails `RefreshRejected` + // rather than serving `A`. Under the bug the still-fresh `A` was a cache + // hit that returned the dead token. + let fresh_opener = ScriptedOpener::new(Script::Approve); + let fresh = PkceOAuthTokenSource::new_with(cfg, Arc::new(fresh_opener.clone())).unwrap(); + let later = fresh.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + later, + Err(AuthError::RefreshRejected), + "a fresh source must not serve the neutralized rejected token from cache" + ); + assert_eq!( + fresh_opener.call_count(), + 0, + "a headless caller never browses" + ); +} + +// ---- P1-1 bounded three-stage neutralization: disk fallback paths ----------- +// +// `expire_rejected()` neutralizes the on-disk token with three-stage fallback: +// 1. Atomic rewrite via `persist()` (temp-file + rename, owner-only perms). +// 2. In-place truncating overwrite via `OpenOptions::write().truncate(true)` — +// succeeds even when the parent directory is non-writable, because only the +// file's own mode matters for writing an existing file. +// 3. `remove_file` as a last resort. +// +// The primary case this tests: a 0600 token file under a 0500 parent directory. +// Temp-file creation (for the atomic path) fails with EACCES; the in-place +// write succeeds because the file itself is owner-writable. After the in-place +// overwrite the file still exists but carries `expires_at = 0`, so a later +// plain `bearer(None)` or a freshly constructed source reads the now-expired +// entry and re-validates over the network instead of serving the dead token. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_token_disk_neutralization_neutralizes_in_place_when_parent_blocks_rewrite() { + use std::os::unix::fs::PermissionsExt as _; + + // Seed unexpired `A` with a live refresh. The provider stickily re-issues `A`. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Create the token file inside a dedicated subdirectory so we can chmod + // just that subdirectory non-writable without affecting the test harness. + let token_dir = cache.path().join("protected"); + std::fs::create_dir_all(&token_dir).unwrap(); + + // Override the config to use the protected subdir. + let cfg = PkceOAuthConfig { + cache_dir_override: Some(token_dir.clone()), + ..cfg + }; + let cache_file = cache_file_path(&cfg, &token_dir); + + seed_cache( + &cfg, + &token_dir, + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + // Build the source: it reads `A` from disk into its in-memory cell. + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // The token file lives at `token_dir/databricks/.json`. Its direct + // parent is `token_dir/databricks/`, not `token_dir` itself — the + // coordinator's `cache_path_for()` appends the namespace subdir. Assert + // the relationship explicitly so a future path-resolution change breaks + // loudly here instead of silently letting the atomic write succeed (which + // would make the test vacuously pass even without the in-place fallback). + let protected_dir = cache_file + .parent() + .expect("cache file must have a parent directory"); + assert_eq!( + protected_dir, + token_dir.join("databricks"), + "cache file's direct parent is token_dir/databricks, not token_dir" + ); + + // Pre-create the advisory lock file so `acquire_auth_lock` can open it + // even after the directory is made non-writable. The lock file must exist + // before the chmod, because `OpenOptions::create(true)` on an existing + // file succeeds regardless of parent-dir permissions, while creating a new + // file in a 0500 directory would EACCES. + let lock_file = { + let mut p = cache_file.as_os_str().to_owned(); + p.push(".lock"); + std::path::PathBuf::from(p) + }; + std::fs::File::create(&lock_file).expect("pre-create lock file before chmod"); + + // Make the direct parent non-writable (0500): temp-file creation for the + // atomic persist requires creating a new file in this directory → EACCES. + // The file itself remains 0600 owner-writable, so the in-place fallback + // path in `expire_rejected` can still open and truncate it. + std::fs::set_permissions(protected_dir, std::fs::Permissions::from_mode(0o500)).unwrap(); + + // Trigger 401-recovery: refresh stickily re-issues `A`, `finish()` rejects + // it typed. `expire_rejected` runs: atomic persist fails (EACCES on parent), + // in-place write succeeds (file mode 0600). + let result = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!( + result, + Err(AuthError::RefreshRejected), + "typed failure returned; neutralization does not disrupt the recovery path" + ); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // Restore write permission so the test harness can clean up. + std::fs::set_permissions(protected_dir, std::fs::Permissions::from_mode(0o700)).unwrap(); + + // The cache file still exists (in-place write, not removal), but its + // `expires_at` should now be 0 — it was overwritten in-place. + assert!( + cache_file.is_file(), + "in-place fallback: file still exists (not removed)" + ); + let raw = std::fs::read(&cache_file).expect("cache file readable after in-place write"); + let cached: serde_json::Value = + serde_json::from_slice(&raw).expect("cache file parseable after in-place write"); + assert_eq!( + cached.get("expires_at").and_then(|v| v.as_u64()), + Some(0), + "in-place write set expires_at = 0: token is now expired on disk" + ); + + // A fresh source constructed after the neutralization must not serve `A`. + let fresh_src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + // The disk token is expired; bearer() falls through to refresh, which + // stickily re-issues `A`, which `finish()` rejects again (no rejected + // identity on this plain call — the disk is now expired, so the source + // enters the refresh path, gets `A` back from the provider, and `finish()` + // sees no rejection guard and would persist it). But with no `rejected` + // passed here, a plain `bearer()` with the now-expired disk entry must + // re-validate. If the in-place write succeeded, the disk token has + // expires_at = 0 and `cached_hit` skips it, so the source goes to refresh. + // We confirm `A` is not served as a cache hit: the stub records a second + // refresh grant. + let _ = fresh_src + .acquire_with_intent(AuthIntent::Headless, None) + .await; + assert!( + stub.refresh_grants.load(Ordering::SeqCst) >= 2, + "fresh source did not serve `A` as a plain cache hit — it re-validated over the network" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_token_in_memory_neutralized_when_disk_neutralization_skipped() { + // When `expire_rejected()` cannot read a matching disk entry (e.g. the cache + // path is not a readable regular file), the disk layer is not neutralized, + // but the IN-MEMORY layer is always neutralized unconditionally. This test + // proves the in-memory safety path: even without disk neutralization, a + // subsequent plain `bearer()` on the same source cannot serve the dead token + // from the in-memory cell. + let stub = spawn_stub_with(RefreshMode::SucceedSticky("A")).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + let cache_file = cache_file_path(&cfg, cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "A", + "refresh_token": "live-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + // Replace the cache file with a directory so `read_private_cache` inside + // `expire_rejected` returns None (EISDIR on open). The disk branch is + // skipped entirely — only the in-memory layer is neutralized. + std::fs::remove_file(&cache_file).unwrap(); + std::fs::create_dir_all(&cache_file).unwrap(); + + let result = src + .acquire_with_intent(AuthIntent::Headless, Some("A")) + .await; + assert_eq!(result, Err(AuthError::RefreshRejected)); + assert_eq!(stub.refresh_grants.load(Ordering::SeqCst), 1); + + // In-memory layer: force-expired. The same source's next plain bearer() + // must not serve `A` from the in-memory cell. + let next = src.acquire_with_intent(AuthIntent::Headless, None).await; + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "in-memory `A` was force-expired; same source went to the network rather than serving the dead token" + ); + // The sticky refresh obtained `A` from the network (grant #2). The persist() + // call fails because the cache path is now a directory — save() maps the + // persist failure to NetworkUnavailable. This proves: (a) the in-memory + // neutralization worked (the source re-validated rather than serving A from + // the expired in-memory cell), and (b) the network was reached. The + // NetworkUnavailable result is an expected artifact of the directory-as- + // cache-path test setup, not a correctness gap. + assert!( + matches!(next, Err(AuthError::NetworkUnavailable)), + "save() fails with NetworkUnavailable on persist failure (expected artifact of test setup)" + ); + assert_ne!( + next, + Ok("A".to_owned()), + "A was not served from the expired in-memory cell — network was reached" + ); + + // Cleanup the directory we created. + std::fs::remove_dir(&cache_file).ok(); +} + +// ---- expired-sibling replacement must not satisfy a 401 recovery ---------- +// +// After a 401, `rejected = Some(t)` makes the expiry clock untrustworthy, so a +// cache hit requires a token that both DIFFERS from `t` and is still unexpired. +// An expired sibling token — one that merely differs from the rejected bytes — +// must NOT be served as the replacement: doing so would skip the refresh the +// 401 demanded and hand back a token the provider will also reject. + +#[cfg(unix)] +#[tokio::test] +async fn test_rejected_recovery_skips_expired_sibling_and_refreshes() { + let stub = spawn_stub(false).await; // refresh succeeds + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // The cached token is a DIFFERENT string from the rejected bytes, but it is + // expired. Under the old "differs is enough" rule it would be returned as + // the sibling replacement; the fix requires it to be unexpired too, so the + // coordinator must fall through to the live refresh instead. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-sibling", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let token = src + .acquire_with_intent(AuthIntent::Headless, Some("rejected-original")) + .await + .expect("an expired sibling forces a refresh rather than being reused"); + assert_eq!( + token, "refreshed-token-1", + "the expired sibling was not accepted; a fresh token was obtained" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "the 401 recovery refreshed instead of reusing the expired sibling" + ); + assert_eq!(opener.call_count(), 0, "a live refresh needs no browser"); +} + +// ---- code-exchange classifier: rejection vs. infrastructure -------------- +// +// The browser code exchange must mirror the refresh classifier: only a 4xx +// `invalid_grant` establishes the authorization code was rejected (terminal, +// cooldown-worthy `ExchangeFailed`). A 429, any 5xx, and a malformed 2xx are a +// transient provider fault that must surface as `NetworkUnavailable` — never +// poisoning the 5-minute cooldown against a provider outage after callback. + +#[tokio::test] +async fn test_exchange_invalid_grant_is_exchange_failed_and_cools_down() { + let stub = spawn_stub_with_exchange(ExchangeMode::Fail( + axum::http::StatusCode::UNAUTHORIZED, + "invalid_grant", + )) + .await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // A genuinely rejected code is terminal ExchangeFailed and is + // cooldown-worthy: a following Auto caller reads the cooldown without a + // second browser. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let first = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + first, + Err(AuthError::ExchangeFailed), + "a 401 invalid_grant on the code exchange is a rejected grant" + ); + assert_eq!(opener.call_count(), 1); + + let second = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + second, + Err(AuthError::ExchangeFailed), + "the rejected exchange wrote a cooldown the next Auto caller honors" + ); + assert_eq!( + opener.call_count(), + 1, + "the cooldown suppressed a second browser launch" + ); +} + +#[tokio::test] +async fn test_exchange_transient_faults_are_network_unavailable_not_cooldown() { + // A 429, a 500, and a malformed 2xx are provider faults, not rejected + // codes: each must surface as NetworkUnavailable and leave no cooldown, so + // a subsequent Auto caller retries with a fresh browser rather than + // inheriting a suppressed outcome. + let cases = [ + ExchangeMode::Fail(axum::http::StatusCode::TOO_MANY_REQUESTS, "slow_down"), + ExchangeMode::Fail( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "temporarily_unavailable", + ), + ExchangeMode::MalformedSuccess, + ]; + for exchange in cases { + let stub = spawn_stub_with_exchange(exchange).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let result = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "a transient exchange fault is infrastructural, not a rejected code" + ); + assert_eq!(opener.call_count(), 1); + + // No cooldown was written, so a second Auto caller launches again + // rather than reading a suppressed outcome. + let retry = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + retry, + Err(AuthError::NetworkUnavailable), + "a transient exchange fault leaves no cooldown to suppress the retry" + ); + assert_eq!( + opener.call_count(), + 2, + "no cooldown means the next Auto caller opens a fresh browser" + ); + } +} + +#[tokio::test] +async fn test_exchange_timeout_is_network_unavailable_not_cooldown() { + // The code exchange hangs far longer than the injected per-request HTTP + // timeout, so the exchange POST times out at the transport layer with no + // verdict from the provider — the transport branch the classifier maps to + // NetworkUnavailable. Like the refresh-timeout test, a short real-time + // timeout is injected rather than pausing the clock: under `start_paused` + // tokio would auto-advance into the timer while the real loopback + // discovery/authorize round-trips are still in flight, tripping the timeout + // on the wrong request. Real time keeps the timeout attached to the + // exchange that actually hangs. + let stub = spawn_stub_with_exchange(ExchangeMode::Hang(Duration::from_secs(30))).await; + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + let src = PkceOAuthTokenSource::new_with_http_timeout( + cfg, + Arc::new(opener.clone()), + Duration::from_millis(300), + ) + .unwrap(); + let result = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + result, + Err(AuthError::NetworkUnavailable), + "an exchange transport timeout is infrastructural, not a rejected code" + ); + assert_eq!(opener.call_count(), 1); + + // The timed-out exchange wrote no cooldown, so a second Auto caller launches + // its own browser rather than inheriting a suppressed outcome. + let retry = src.acquire_with_intent(AuthIntent::Auto, None).await; + assert_eq!( + retry, + Err(AuthError::NetworkUnavailable), + "an exchange transport timeout leaves no cooldown to suppress the retry" + ); + assert_eq!( + opener.call_count(), + 2, + "no cooldown means the next Auto caller opens a fresh browser" + ); +} + +// ---- genuine cross-process lock contention and crash release ------------- +// +// The single-flight guarantee and its crash-release property are cross-process +// claims, so they need a real second process — not a second in-process handle — +// on the same lock file. The `lock-holder` helper binary takes the +// coordinator's advisory lock and holds it until killed; killing it models a +// crash mid-flow, and the kernel's release of the advisory lock is what lets +// the coordinator's successor proceed with no PID files and no lock breaking. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_lock_holder_blocks_then_crash_release_lets_successor_proceed() { + let stub = spawn_stub(false).await; // refresh succeeds once the lock is free + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Expired token with a LIVE refresh: a cache miss forces the coordinator + // onto the slow path (it must take the lock), and once the lock is free the + // refresh recovers a token without any browser — so success is a clean + // signal that the successor proceeded. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let lock_path = lock_file_path(&cfg, cache.path()); + let ready_marker = cache.path().join("holder.ready"); + + // A real second process grabs the lock and holds it. + let mut holder = tokio::process::Command::new(env!("CARGO_BIN_EXE_lock-holder")) + .env("LOCK_HELPER_PATH", &lock_path) + .env("LOCK_HELPER_READY", &ready_marker) + .kill_on_drop(true) + .spawn() + .expect("spawn the lock-holder helper process"); + + // Synchronize on real lock ownership before racing the coordinator. + for _ in 0..600 { + if ready_marker.exists() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!( + ready_marker.exists(), + "lock-holder never signaled that it holds the lock" + ); + + // The coordinator cannot make progress while another process holds the + // lock: it polls the advisory lock rather than stealing it. + let src = PkceOAuthTokenSource::new_with(cfg, Arc::new(opener.clone())).unwrap(); + let task = + tokio::spawn(async move { src.acquire_with_intent(AuthIntent::Headless, None).await }); + tokio::time::sleep(Duration::from_millis(400)).await; + assert!( + !task.is_finished(), + "coordinator must block while a live process holds the cross-process lock" + ); + + // Kill the holder: the kernel releases the advisory lock on process death, + // with no PID file inspection or lock breaking on our side. + holder.kill().await.expect("kill the lock holder"); + holder.wait().await.ok(); + + let token = task + .await + .expect("acquisition task joins") + .expect("successor proceeds once the crashed holder's lock is released"); + assert_eq!( + token, "refreshed-token-1", + "successor completes the refresh after acquiring the freed lock" + ); + assert_eq!( + opener.call_count(), + 0, + "Headless successor recovers via refresh without a browser" + ); +} + +// ---- genuine cross-process coordinator races ----------------------------- +// +// The `auth-worker` helper is a real second process running the PUBLIC +// coordinator API against the shared cache. Unlike two in-process handles +// (which the `INFLIGHT` registry coalesces before the file lock), these +// workers contend on the OS advisory lock and share success through the +// on-disk cache exactly as two Buzz processes on one machine would. + +/// A spawned `auth-worker`: its child handle plus the file it writes its JSON +/// outcome to. +struct Worker { + child: tokio::process::Child, + result_path: std::path::PathBuf, +} + +#[derive(Deserialize)] +struct WorkerOutcome { + result: String, + #[cfg(unix)] + bearer: Option, + launches: u64, +} + +impl Worker { + /// Block until the worker exits, then parse its outcome file. + async fn join(mut self) -> WorkerOutcome { + let status = self.child.wait().await.expect("auth-worker joins"); + assert!( + status.success(), + "auth-worker exited with failure: {status}" + ); + let body = std::fs::read(&self.result_path).expect("auth-worker wrote its outcome"); + serde_json::from_slice(&body).expect("auth-worker outcome parses") + } +} + +/// Spawn an `auth-worker` child against `cfg`'s shared cache. `extra` sets the +/// optional barrier-marker env vars ((name, path) pairs) a scenario needs to +/// order events across processes. +fn spawn_worker( + cfg: &PkceOAuthConfig, + cache_dir: &std::path::Path, + intent: &str, + script: &str, + tag: &str, + extra: &[(&str, &std::path::Path)], +) -> Worker { + let result_path = cache_dir.join(format!("{tag}.result.json")); + let mut cmd = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd.env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache_dir) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", intent) + .env("AUTH_WORKER_SCRIPT", script) + .env("AUTH_WORKER_RESULT", &result_path) + .kill_on_drop(true); + for (key, path) in extra { + cmd.env(key, path); + } + let child = cmd.spawn().expect("spawn the auth-worker helper process"); + Worker { child, result_path } +} + +async fn wait_for_marker(path: &std::path::Path, what: &str) { + for _ in 0..1000 { + if path.exists() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("timed out waiting for {what} ({})", path.display()); +} + +#[tokio::test] +async fn test_crossprocess_userinitiated_denial_shared_with_waiting_auto() { + // Two real processes on one key. The child runs a UserInitiated flow that + // is denied; while it holds the lock and its browser is open, the parent's + // Auto coordinator is already WAITING on the cross-process lock. The child + // must be released only once the parent is queued, so the denial the child + // records is what the waiting Auto observes — one launch total, durable + // Denied for both, across a genuine process boundary. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let launched = cache.path().join("child.launched"); + let proceed = cache.path().join("child.proceed"); + let child = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "denier", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed.as_path()), + ], + ); + + // Wait until the child holds the lock and has opened its (scripted) + // browser; its callback is withheld until we create `proceed`. + wait_for_marker(&launched, "child browser launch").await; + + // The parent's Auto coordinator now contends for the same lock. It cannot + // proceed while the child holds it, so it is a genuine cross-process + // waiter. + let parent = PkceOAuthTokenSource::new_with( + config(&stub, "/disco/a", cache.path()), + Arc::new(ScriptedOpener::new(Script::Approve)), + ) + .unwrap(); + let auto = + tokio::spawn(async move { parent.acquire_with_intent(AuthIntent::Auto, None).await }); + tokio::time::sleep(Duration::from_millis(300)).await; + assert!( + !auto.is_finished(), + "parent Auto must block while the child process holds the lock" + ); + + // Release the child's callback: it finishes the denial and writes the + // cooldown sidecar, then drops the lock. + std::fs::write(&proceed, b"go").unwrap(); + + let child_outcome = child.join().await; + assert_eq!( + child_outcome.result, "denied", + "child UserInitiated is denied" + ); + assert_eq!(child_outcome.launches, 1, "child opens exactly one browser"); + + let auto_result = auto.await.expect("parent Auto task joins"); + assert_eq!( + auto_result, + Err(AuthError::Denied), + "the already-waiting Auto reads the child's durable denial" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 0, + "a denied flow never reaches the code exchange" + ); +} + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_two_coordinators_race_to_one_grant_and_cache() { + // Two real coordinator processes race on one key from a cold cache. They + // are released together (via a shared start marker) so both contend for the + // lock. Exactly one wins the browser flow and performs the single code + // grant; the other serializes behind the lock and adopts the winner's token + // from the shared cache. Both must observe the same bearer, and the private + // cache must hold exactly one parseable token artifact. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let ready_a = cache.path().join("a.ready"); + let ready_b = cache.path().join("b.ready"); + let start = cache.path().join("start"); + + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "a", + &[ + ("AUTH_WORKER_READY_MARKER", ready_a.as_path()), + ("AUTH_WORKER_START_MARKER", start.as_path()), + ], + ); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "b", + &[ + ("AUTH_WORKER_READY_MARKER", ready_b.as_path()), + ("AUTH_WORKER_START_MARKER", start.as_path()), + ], + ); + + // Both processes are built and about to acquire; release them together. + wait_for_marker(&ready_a, "worker A ready").await; + wait_for_marker(&ready_b, "worker B ready").await; + std::fs::write(&start, b"go").unwrap(); + + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + assert_eq!(out_a.result, "ok", "worker A authenticates"); + assert_eq!(out_b.result, "ok", "worker B authenticates"); + let bearer_a = out_a.bearer.expect("worker A returns a bearer"); + let bearer_b = out_b.bearer.expect("worker B returns a bearer"); + assert_eq!( + bearer_a, bearer_b, + "both processes observe the same bearer from the shared cache" + ); + + // Exactly one browser launch and one code exchange across both processes. + assert_eq!( + out_a.launches + out_b.launches, + 1, + "exactly one browser launch across the two coordinator processes" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one authorization-code exchange across both processes" + ); + + // The private cache holds exactly one parseable token artifact carrying the + // shared bearer. + let cache_path = cache_file_path(&cfg, cache.path()); + let raw = std::fs::read(&cache_path).expect("cache file exists"); + let cached: serde_json::Value = + serde_json::from_slice(&raw).expect("cache holds one parseable token artifact"); + assert_eq!( + cached.get("access_token").and_then(|v| v.as_str()), + Some(bearer_a.as_str()), + "the cached token is the shared bearer" + ); +} + +// ---- cross-process failure single-flight (attempt-record protocol) -------- +// +// `INFLIGHT` coalesces same-key callers within one process before they reach +// the file lock, so two separate processes both queued on the lock do NOT +// share the in-process registry. Without the attempt-record protocol, a +// process that acquires the lock AFTER the holder fails would re-run the +// full flow from scratch — a second browser launch on `Denied`, or a second +// dead-refresh call on `RefreshRejected`. The attempt sidecar lets the +// second process detect that the predecessor completed while it was waiting +// and adopt its failure directly. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_waiting_headless_adopts_predecessor_refresh_rejected() { + // Two real headless processes on one key. The cache holds an expired + // token with a dead refresh. A wins the lock and calls the stub; the + // stub holds A's response so B can deterministically snapshot gen=0 + // and queue on the lock before A completes. Once B's snapshot marker + // fires, A is released: it gets `invalid_grant`, writes the attempt + // sidecar (gen=1), and releases the lock. B acquires the lock, sees + // gen=1 > snap=0, and adopts `RefreshRejected` — ONE refresh grant + // total across both processes. + // + // This replaces the prior simultaneous-start design, which was not + // deterministic: the instant-reject stub could complete A before B + // ever snapshotted, giving B snap=1 and causing a spurious second + // refresh grant. + let (stub, gate) = spawn_stub_with_held_refresh(HeldRefreshResponse::Reject).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Seed the shared cache: expired token with a dead refresh, so both + // workers fall through to the refresh grant rather than a cache hit. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "stale", + "refresh_token": "dead-refresh", + "expires_at": 1u64, + }), + ); + + let snapshot_b = cache.path().join("b.snapshot"); + + // ---- Phase 1: spawn A. It acquires the lock and immediately calls the + // stub's refresh endpoint; the stub holds the response. + let worker_a = spawn_worker(&cfg, cache.path(), "headless", "approve", "a", &[]); + + // ---- Phase 2: wait until the stub has received A's refresh request. + // This is an in-process await — no polling or timing. + // Once the stub is holding A's request, A owns the lock. + gate.wait_for_request().await; + + // ---- Phase 3: spawn B with SNAPSHOT_MARKER. B starts, reads the + // attempt sidecar (gen=0, absent), emits its snapshot + // event, and then blocks on the lock behind A. + let worker_b = spawn_worker( + &cfg, + cache.path(), + "headless", + "approve", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + + // ---- Phase 4: wait for B's snapshot marker. Proves B captured gen=0 + // before A can record gen=1; lock queueing is not required + // for the temporal-generation discriminator to hold. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 5: release A. Stub returns invalid_grant; A records + // RefreshRejected with gen=1 and releases the lock. B + // acquires the lock, sees gen=1 > snap=0, and adopts. + gate.release(); + + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + + // Both workers must report RefreshRejected. + assert_eq!( + out_a.result, "refresh_rejected", + "worker A gets RefreshRejected on a dead refresh" + ); + assert_eq!( + out_b.result, "refresh_rejected", + "worker B adopts RefreshRejected via the attempt sidecar" + ); + assert_eq!(out_a.launches, 0, "headless never opens a browser"); + assert_eq!(out_b.launches, 0, "headless never opens a browser"); + + // One refresh grant total: under the old protocol the second worker would + // re-run the dead refresh independently; the attempt record prevents that. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 1, + "exactly one refresh grant across both headless processes" + ); +} + +#[tokio::test] +async fn test_crossprocess_userinitiated_waiter_adopts_predecessor_denial() { + // The adoption contract is *temporal*, not intent-based. A `UserInitiated` + // caller whose pre-queue snapshot is older than the current generation was + // already queued while the predecessor ran and MUST adopt its same-intent + // failure — exactly as the in-process `INFLIGHT` registry coalesces + // same-intent `UserInitiated` callers onto one leader within a process. + // + // When process A (UserInitiated) gets `Denied` and process B + // (UserInitiated) was queued *behind* it (B's snapshot predates A's write), + // B adopts A's denial without opening a second browser. The result: + // exactly one browser launch and zero code exchanges — one browser total + // across both processes. + // + // Note: this is different from a *later* explicit user retry, which + // arrives after A completes, snapshots the new generation, sees no advance, + // and naturally runs its own attempt. That behavior is proved by + // `test_crossprocess_post_failure_userinitiated_runs_own_attempt` below. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + let launched_a = cache.path().join("a.launched"); + let proceed_a = cache.path().join("a.proceed"); + + // Worker A holds the lock and keeps its browser open until we signal it, + // so B is certain to be queued behind A before A resolves. + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "a", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched_a.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed_a.as_path()), + ], + ); + + // Wait until A holds the lock and its browser is open. + wait_for_marker(&launched_a, "worker A browser launch").await; + + // Worker B (also UserInitiated, approve-scripted) queues behind A on the + // file lock. Even though B would succeed if it ran its own browser, it + // must adopt A's denial since it was queued while A held the lock. + // + // SNAPSHOT_MARKER is emitted by the tracing layer in B's process after B + // snapshots gen=0 and before it queues on the file lock — so observing it + // proves B captured generation 0 before A records generation 1. + let snapshot_b = cache.path().join("b.snapshot"); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "approve", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + // Wait until B has snapshotted gen=0, then release A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // Release A: it denies, writes the cooldown + attempt sidecars, releases lock. + std::fs::write(&proceed_a, b"go").unwrap(); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens one browser"); + + // Worker B adopts A's denial — it does not open a second browser even + // though it is UserInitiated. Under the old contract B would open its own + // browser and succeed; under the correct temporal contract it adopts. + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "denied", + "queued UserInitiated worker B adopts A's denial rather than re-running" + ); + assert_eq!( + out_b.launches, 0, + "worker B adopts the denial without opening a browser" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 0, + "no code exchange — B adopted A's Denied without reaching the token endpoint" + ); +} + +#[tokio::test] +async fn test_crossprocess_post_failure_userinitiated_runs_own_attempt() { + // A `UserInitiated` caller that arrives *after* a failure — not queued + // during it — snapshots the current (advanced) generation, sees no advance + // when it acquires the lock, and runs its own attempt. "Later explicit user + // retry bypasses" falls out of the temporal snapshot comparison without any + // special case. + let stub = spawn_stub(false).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Worker A (UserInitiated, deny-scripted) runs to completion first. No + // synchronization needed — we await it fully before constructing B. + let worker_a = spawn_worker(&cfg, cache.path(), "userinitiated", "deny", "a", &[]); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens one browser"); + + // Worker B arrives after A has fully completed and the attempt record is + // already written with the new generation. B snapshots the current + // (advanced) generation, acquires the lock, sees no further advance, and + // runs its own browser flow — it should succeed. + let worker_b = spawn_worker(&cfg, cache.path(), "userinitiated", "approve", "b", &[]); + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "ok", + "post-failure UserInitiated worker B runs its own flow and succeeds" + ); + assert_eq!( + out_b.launches, 1, + "worker B opens its own browser (not inherited from A)" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange (worker B's own approval)" + ); +} + +// ---- cross-process: adopter must NOT re-write the attempt generation ------- +// +// Proves that an adopting process B does not advance the attempt-sidecar +// generation, so a third process C — which arrives AFTER A's failure but sees +// no generation advance (B didn't re-write) — correctly runs its own attempt. +// +// Protocol ordering (deterministic via markers, no timing): +// 1. A (UserInitiated, deny-scripted) holds the lock mid-browser via +// LAUNCHED_MARKER + PROCEED_MARKER. +// 2. B (UserInitiated, deny-scripted) starts while A holds the lock. +// B emits SNAPSHOT_MARKER after snapshotting gen=0 and before queueing +// on the lock. Parent observes the marker, then signals A's proceed. +// 3. A: denial recorded, writes gen=1 to the attempt sidecar, releases lock. +// 4. B: acquires lock, sees gen=1 > snap=0, intent matches → adopts A's +// denial. With the fix B does NOT re-write the sidecar. With the mutation +// (restoring the deleted write_attempt at the adoption site) B writes +// gen=2. +// 5. After A and B finish: assert sidecar generation == 1. This is the +// discriminating assertion — it FAILS when the adoption-site re-write is +// restored (gen becomes 2 instead of 1). +// 6. C (UserInitiated, approve-scripted) starts fresh. C's snapshot == gen +// on disk (1 with fix, 2 with mutation). In both cases C sees no advance +// and runs its own browser flow. code_grants increments by 1 for C. +// +// This test is cache-free (no seed_cache / disk-token assertions) so it runs +// on Windows as well as Unix. + +#[tokio::test] +async fn test_crossprocess_adopter_does_not_advance_generation() { + let stub = spawn_stub(false).await; // deny does not hit any endpoint + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // ---- Phase 1: A holds the lock mid-browser ---------------------------- + let launched_a = cache.path().join("a.launched"); + let proceed_a = cache.path().join("a.proceed"); + + let worker_a = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "a", + &[ + ("AUTH_WORKER_LAUNCHED_MARKER", launched_a.as_path()), + ("AUTH_WORKER_PROCEED_MARKER", proceed_a.as_path()), + ], + ); + + // Wait until A holds the lock and its browser is open. + wait_for_marker(&launched_a, "worker A browser launch").await; + + // ---- Phase 2: B queues behind A, snapshot barrier --------------------- + // B is UserInitiated + deny-scripted, but B will adopt A's denial rather + // than opening its own browser (B was queued while A held the lock). + // SNAPSHOT_MARKER is emitted by the tracing layer in B's process after B + // snapshots gen=0 and before it queues on the lock — so observing it + // proves B captured generation 0 before A records generation 1. + let snapshot_b = cache.path().join("b.snapshot"); + let worker_b = spawn_worker( + &cfg, + cache.path(), + "userinitiated", + "deny", + "b", + &[("AUTH_WORKER_SNAPSHOT_MARKER", snapshot_b.as_path())], + ); + + // Wait until B has snapshotted gen=0, then release A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 3: release A, let A fail and write gen=1 ------------------- + std::fs::write(&proceed_a, b"go").unwrap(); + let out_a = worker_a.join().await; + assert_eq!(out_a.result, "denied", "worker A is denied"); + assert_eq!(out_a.launches, 1, "worker A opens exactly one browser"); + + // ---- Phase 4: B adopts (does NOT re-write the sidecar) ---------------- + let out_b = worker_b.join().await; + assert_eq!( + out_b.result, "denied", + "worker B adopts A's denial — it does not open a second browser" + ); + assert_eq!( + out_b.launches, 0, + "worker B adopts without opening a browser" + ); + + // ---- Phase 5: discriminating generation check ------------------------- + // With the fix: sidecar gen == 1 (B did not re-write). + // Mutation check: restore the deleted `write_attempt` at the adoption site + // → B writes gen=2 → this assertion FAILS. + let sidecar = attempt_sidecar_path(&cfg, cache.path()); + let raw = std::fs::read(&sidecar).expect("attempt sidecar written by A"); + let record: serde_json::Value = serde_json::from_slice(&raw).expect("sidecar parses as JSON"); + assert_eq!( + record.get("generation").and_then(|v| v.as_u64()), + Some(1), + "adopter B must not advance the sidecar generation (gen must stay at 1, not 2)" + ); + + // ---- Phase 6: C runs its own attempt ---------------------------------- + // C arrives after A's failure. C's snapshot equals the on-disk generation + // (1 with fix, 2 with mutation). Either way C sees no advance and runs its + // own browser flow. But the sidecar check above already catches the + // mutation; C proves the end-to-end behaviour. + let worker_c = spawn_worker(&cfg, cache.path(), "userinitiated", "approve", "c", &[]); + let out_c = worker_c.join().await; + assert_eq!( + out_c.result, "ok", + "worker C (fresh arrival after A's failure) runs its own flow and succeeds" + ); + assert_eq!( + out_c.launches, 1, + "worker C opens its own browser — not inherited from A or B" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "exactly one code exchange — C's own approval (A was denied; B adopted without exchange)" + ); +} + +// ---- cross-process: a waiter with a different rejected must not inherit ---- +// +// Cross-process mirror of the in-process test above: process A carries +// `rejected = "X"` and the refresh stickily re-issues "X" → A's attempt +// records RefreshRejected with `rejected_digest = sha256("X")`. Process B +// waits on the lock with `rejected = "Y"` (different). When B acquires the +// lock and reads the attempt record, the digest mismatch causes B to run its +// own attempt rather than adopt A's failure — B's refresh gets "X", which is +// valid for B, so B succeeds. +// +// Ordering is established with deterministic markers and the in-process stub +// gate, not timing: +// 1. A spawns (headless, rejected="X"). The stub holds A's refresh response +// until the parent calls `gate.release()`. +// 2. Parent waits for `gate.wait_for_request()` — proves A has acquired the +// lock and is mid-refresh (the request arrived at the stub). +// 3. Parent spawns B (headless, rejected="Y", SNAPSHOT_MARKER=b.snapshot). +// 4. Parent waits for B's snapshot marker — proves B has snapshotted gen=0 +// and is queued on the lock. +// 5. Parent calls `gate.release()`: stub returns "X" to A. A finishes with +// RefreshRejected(digest(X)), writes sidecar gen=1, releases lock. +// 6. B acquires: gen=1 > snap=0, digest(Y) ≠ digest(X) → B runs its own +// refresh → gets "X" → Ok("X"). +// +// Mutation check (no digest gating): B adopts A's RefreshRejected → +// refresh_grants stays at 1 → `refresh_grants == 2` assertion FAILS. + +#[cfg(unix)] +#[tokio::test] +async fn test_crossprocess_waiter_with_different_rejected_does_not_adopt_leaders_failure() { + // Stub stickily returns "X" but holds each response until released. + let (stub, gate) = spawn_stub_with_held_refresh(HeldRefreshResponse::Sticky("X")).await; + let cache = TempDir::new().unwrap(); + let cfg = config(&stub, "/disco/a", cache.path()); + + // Seed a token entry so both workers have a refresh token to exercise. + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "expired-seed", + "refresh_token": "live-refresh", + "expires_at": 1u64, + }), + ); + + let result_a = cache.path().join("a.result.json"); + let result_b = cache.path().join("b.result.json"); + let snapshot_b = cache.path().join("b.snapshot"); + + // ---- Phase 1: spawn A. A will acquire the lock and immediately call the + // stub's refresh endpoint; the stub holds the response. + let mut cmd_a = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd_a + .env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache.path()) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", "headless") + .env("AUTH_WORKER_SCRIPT", "failopen") // headless never browses + .env("AUTH_WORKER_REJECTED", "X") + .env("AUTH_WORKER_RESULT", &result_a) + .kill_on_drop(true); + + let child_a = cmd_a.spawn().expect("spawn worker A"); + + // ---- Phase 2: wait until the stub has received A's refresh request. + // This is an in-process await — no polling or timing needed. + // Once the stub is holding A's request, A owns the lock. + gate.wait_for_request().await; + + // ---- Phase 3: spawn B with SNAPSHOT_MARKER. + let mut cmd_b = tokio::process::Command::new(env!("CARGO_BIN_EXE_auth-worker")); + cmd_b + .env("AUTH_WORKER_DISCOVERY_URL", &cfg.discovery_url) + .env("AUTH_WORKER_CACHE_DIR", cache.path()) + .env("AUTH_WORKER_NAMESPACE", &cfg.cache_namespace) + .env("AUTH_WORKER_CLIENT_ID", &cfg.client_id) + .env("AUTH_WORKER_SCOPES", cfg.scopes.join(",")) + .env("AUTH_WORKER_INTENT", "headless") + .env("AUTH_WORKER_SCRIPT", "failopen") + .env("AUTH_WORKER_REJECTED", "Y") + .env("AUTH_WORKER_RESULT", &result_b) + .env("AUTH_WORKER_SNAPSHOT_MARKER", &snapshot_b) + .kill_on_drop(true); + + let child_b = cmd_b.spawn().expect("spawn worker B"); + + // ---- Phase 4: wait for B's snapshot marker. The tracing layer in B fires + // this after B snapshots gen=0 and before it waits for the + // lock — proves B holds snap=0 and is queued behind A. + wait_for_marker(&snapshot_b, "worker B snapshot").await; + + // ---- Phase 5: release A. Stub returns "X"; A records RefreshRejected + // with digest(X), advances gen to 1, releases the lock. + gate.release(); + + let worker_a = Worker { + child: child_a, + result_path: result_a, + }; + let worker_b = Worker { + child: child_b, + result_path: result_b, + }; + let (out_a, out_b) = tokio::join!(worker_a.join(), worker_b.join()); + + // A (rejected=X): refresh returns "X" → RefreshRejected. + // Sidecar: gen=1, result=refresh_rejected, rejected_digest=sha256("X"). + assert_eq!( + out_a.result, "refresh_rejected", + "worker A (rejected=X) must get RefreshRejected" + ); + // B (rejected=Y): gen=1 > snap=0, digest(Y) ≠ digest(X) → B runs its + // own refresh. B's refresh returns "X"; finish(rejected=Y, token=X) → Ok. + assert_eq!( + out_b.result, "ok", + "worker B (rejected=Y) must succeed after rerunning — not adopt A's RefreshRejected" + ); + // Mutation check (r8 shape, no digest gate): B adopts → refresh_grants + // stays 1. With the digest fix: B reruns → refresh_grants = 2. + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 2, + "both workers run their own refresh — digest mismatch prevented adoption" + ); +} + +// ---- P1-3 non-Unix read path disabled ----------------------------------- +// +// On non-Unix platforms (Windows) token files written by older builds with +// default ACLs should not be consumed by new builds. `read_private_cache` +// returns an error on non-Unix (and opportunistically removes the legacy +// file), so `read_cache` yields `None` and the source behaves as if no +// cached token exists — memory-only cache on non-Unix. +// +// This test uses a cfg-gated stub: on Unix it only exercises the Unix read +// path (as a sanity check); the Windows behavior is proved by the +// `#[cfg(not(unix))]` branch of `read_private_cache` and verified by the +// Windows CI build + manual testing on the Windows runner. The test is written +// to compile on all platforms and asserts the platform-appropriate invariant. + +#[tokio::test] +async fn test_non_unix_does_not_serve_legacy_on_disk_token() { + // Seed a token that would be served from disk on Unix (unexpired, valid). + let stub = spawn_stub(false).await; // fresh token on refresh/browser + let cache = TempDir::new().unwrap(); + let opener = ScriptedOpener::new(Script::Approve); + let cfg = config(&stub, "/disco/a", cache.path()); + + seed_cache( + &cfg, + cache.path(), + json!({ + "access_token": "legacy-windows-token", + "refresh_token": "legacy-refresh", + "expires_at": future_secs(), + }), + ); + + let src = PkceOAuthTokenSource::new_with(cfg.clone(), Arc::new(opener.clone())).unwrap(); + + #[cfg(unix)] + { + // On Unix the cache is read and served directly from disk — this is the + // expected behavior on a secured platform. + let token = src + .acquire_with_intent(AuthIntent::Headless, None) + .await + .expect("Unix serves the seeded token from disk"); + assert_eq!(token, "legacy-windows-token", "Unix: disk token served"); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 0, + "Unix: no refresh — the disk token was served directly" + ); + // The seeded file is still on disk (not removed on Unix). + assert!( + cache_file_path(&cfg, cache.path()).exists(), + "Unix: the cache file is preserved" + ); + } + + #[cfg(not(unix))] + { + // On non-Unix `read_private_cache` refuses to read the legacy file and + // attempts to remove it. Construction and bearer() behave as if no cache + // exists — the source falls through to a browser flow. + let token = src + .acquire_with_intent(AuthIntent::Auto, None) + .await + .expect("non-Unix: browser flow succeeds (no disk token served)"); + assert_ne!( + token, "legacy-windows-token", + "non-Unix: legacy token must not be served from disk" + ); + assert_eq!( + stub.code_grants.load(Ordering::SeqCst), + 1, + "non-Unix: browser flow ran — disk token was not served" + ); + assert_eq!( + stub.refresh_grants.load(Ordering::SeqCst), + 0, + "non-Unix: no refresh grant — the source went straight to the browser flow" + ); + // The legacy file should have been removed by read_private_cache. + assert!( + !cache_file_path(&cfg, cache.path()).exists(), + "non-Unix: legacy cache file is removed by read_private_cache" + ); + // No new token file was written (persist is a no-op on non-Unix). + // (The token is held in memory only.) + assert!( + !cache_file_path(&cfg, cache.path()).exists(), + "non-Unix: no new cache file created (memory-only)" + ); + } +} diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index fefd5a24c5d..9822243d5fe 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -80,19 +80,35 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc, ) -> (String, Arc>>) { + let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); + let url = spawn_capturing_fake_llm_core(responses, captures.clone(), None).await; + (url, captures) +} + +/// Shared connection loop for the capturing fake LLM: reads each request, +/// records its JSON body into `captures`, and replies with the next canned +/// response. When `gate` is `Some`, the FIRST request's response is withheld +/// until the gate fires; when `None`, every response is served immediately. +async fn spawn_capturing_fake_llm_core( + responses: Vec, + captures: Arc>>, + gate: Option>>>>, +) -> String { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let url = format!("http://{}", listener.local_addr().unwrap()); let queue = Arc::new(Mutex::new(VecDeque::from(responses))); - let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); - let captures_clone = captures.clone(); tokio::spawn(async move { + let mut request_num = 0usize; loop { let (mut sock, _) = match listener.accept().await { Ok(p) => p, Err(_) => return, }; let queue = queue.clone(); - let captures = captures_clone.clone(); + let captures = captures.clone(); + let gate = gate.clone(); + request_num += 1; + let req_num = request_num; tokio::spawn(async move { // Read headers. let mut buf = Vec::new(); @@ -141,6 +157,15 @@ async fn spawn_capturing_fake_llm_with_statuses( captures.lock().await.push(parsed); } + // Hold the first request's response until the gate opens. + if req_num == 1 { + if let Some(gate) = &gate { + if let Some(rx) = gate.lock().await.take() { + let _ = rx.await; + } + } + } + // Send canned response. let response = queue.lock().await.pop_front().unwrap_or(CannedResponse { status: 500, @@ -164,6 +189,20 @@ async fn spawn_capturing_fake_llm_with_statuses( }); } }); + url +} + +/// A capturing fake LLM whose FIRST provider response is withheld until +/// `gate` fires. Later responses are served immediately. Used to make +/// round-boundary races deterministic: hold round 1 open until a client action +/// (e.g. a steer) is confirmed, so the second round observes it. Request bodies +/// are recorded into `captures` exactly as `spawn_capturing_fake_llm` does. +async fn spawn_gated_capturing_fake_llm( + responses: Vec, + captures: Arc>>, + gate: Arc>>>, +) -> (String, Arc>>) { + let url = spawn_capturing_fake_llm_core(responses, captures.clone(), Some(gate)).await; (url, captures) } @@ -774,14 +813,37 @@ async fn recv_active_run_id(h: &mut Harness) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn steer_folds_into_active_turn_without_cancelling() { + use tokio::sync::oneshot; + // A two-round turn (tool call → text). A steer sent once the run is live // must (a) be accepted with the matching runId, (b) NOT cancel the turn — // it still ends with end_turn — and (c) reach the provider as a user turn. - let (url, captures) = spawn_capturing_fake_llm(vec![ - openai_tool_call("call_steer", "fake__noop", json!({})), - openai_text("acknowledged the steer"), - ]) - .await; + // + // The steer is drained only at a round boundary (before the next provider + // request), so it must be enqueued before round 2 begins. Without + // synchronization a fast worker can complete round 1, drain an empty steer + // queue at the round-2 boundary, and dispatch round 2 before the steer is + // even sent — the steer then lands after the turn ends and never reaches + // the provider. To make this deterministic, the FIRST provider response is + // gated: it is withheld until the steer has been sent AND observed + // accepted, so round 1 cannot complete (and round 2 cannot start its drain) + // until the steer is already queued. + let (gate_tx, gate_rx) = oneshot::channel::<()>(); + let gate_rx = Arc::new(Mutex::new(Some(gate_rx))); + + let responses = vec![ + CannedResponse { + status: 200, + body: openai_tool_call("call_steer", "fake__noop", json!({})), + }, + CannedResponse { + status: 200, + body: openai_text("acknowledged the steer"), + }, + ]; + let captures: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (url, _) = spawn_gated_capturing_fake_llm(responses, captures.clone(), gate_rx).await; + let mut h = Harness::spawn(&url).await; let sid = init_session(&mut h).await; @@ -795,7 +857,8 @@ async fn steer_folds_into_active_turn_without_cancelling() { ) .await; - // Learn the run id, then steer into it before the turn finishes. + // Learn the run id (advertised before the gated round-1 request), then steer + // into the live turn while round 1 is still held. let run_id = recv_active_run_id(&mut h).await; let steer_text = "STEER-CANARY: also consider the edge case"; let s_id = h @@ -809,9 +872,12 @@ async fn steer_folds_into_active_turn_without_cancelling() { ) .await; - // Steer is accepted and echoes the run id it landed in. + // Steer is accepted and echoes the run id it landed in. Only after this + // confirmation do we release the gate, so the steer is guaranteed queued + // before round 2's boundary drains it. let mut steer_ok = false; let mut end_turn = false; + let mut gate = Some(gate_tx); for _ in 0..40 { let v = h.recv().await; if v["id"] == json!(s_id) { @@ -827,6 +893,11 @@ async fn steer_folds_into_active_turn_without_cancelling() { "steer reply carries a messageId" ); steer_ok = true; + // Steer accepted — release round 1 so the turn proceeds to round 2, + // whose boundary now drains the queued steer. + if let Some(tx) = gate.take() { + let _ = tx.send(()); + } } else if v["id"] == json!(p_id) { // The turn was NOT cancelled — it completed normally. assert_eq!(v["result"]["stopReason"], "end_turn"); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index e096960ad44..30c46e2fd96 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -31,7 +31,8 @@ pub mod error; mod test_support; pub use runtime::{ - insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, ReadSession, + insert_mentions, migration, replica_fence, Db, DbConfig, DbPoolStats, DbReadinessOutcome, + ReadSession, }; pub(crate) use runtime::{ insert_mentions_in_transaction, observability, route_proof, ReadSessionInner, RouteDecision, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index 2adc1264eeb..68071e0ed24 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -193,8 +193,8 @@ mod postgres_tests { const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Connection parameters parsed out of a `postgres://user:pass@host:port/db` - /// URL so the parity test can pass them to the `bin/pgschema` binary, which + /// Connection parameters parsed out of a PostgreSQL URL so the parity test + /// can pass them to the `bin/pgschema` binary, which /// takes discrete `--host/--port/--user/--password/--db` flags rather than a /// URL. Only the shapes this test emits (`BUZZ_TEST_DATABASE_URL` / /// `DATABASE_URL` / `TEST_DB_URL`) are supported. @@ -699,7 +699,7 @@ mod postgres_tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 42); + assert_eq!(migrations.len(), 43); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1280,6 +1280,18 @@ mod postgres_tests { extract_excluded_table_array(desired_schema), "schema.sql exclusion list drifted from migration 0042" ); + + // Brownfield relay databases created through SQLx still carry the + // production/sandbox constraint from 0015. Converge them to the same + // dogfood-only authority declared by the desired-state schema. + assert_eq!(migrations[42].version, 43); + let dogfood_profile = migrations[42].sql.as_str(); + assert!(dogfood_profile.contains("DELETE FROM push_gateway_delegations")); + assert!(dogfood_profile.contains("DELETE FROM push_gateway_installations")); + assert!(dogfood_profile + .contains("DROP CONSTRAINT push_gateway_installations_app_profile_check")); + assert!(dogfood_profile.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); + assert!(desired_schema.contains("CHECK (app_profile = 'buzz-ios-dogfood')")); } #[test] diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 693d75d66b2..214cc4bca60 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -425,6 +425,26 @@ pub struct DbPoolStats { pub max: u32, } +/// Bounded outcome of the Postgres portion of a relay readiness check. +/// +/// The variants deliberately separate waiting for a pooled connection from +/// executing the health query. Callers may safely use the variant names as +/// low-cardinality metric labels; detailed SQLx errors remain in logs rather +/// than becoming labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DbReadinessOutcome { + /// A writer-pool connection was acquired and `SELECT 1` succeeded. + Success, + /// No writer-pool connection became available before the readiness deadline. + PoolTimeout, + /// The writer pool returned a non-timeout acquisition error. + PoolError, + /// A connection was acquired, but `SELECT 1` exceeded the readiness deadline. + QueryTimeout, + /// A connection was acquired, but `SELECT 1` returned an error. + QueryError, +} + /// Configuration for the Postgres connection pool. #[derive(Debug, Clone)] pub struct DbConfig { @@ -931,11 +951,50 @@ impl Db { migration::run_migrations(&self.pool).await } - /// Returns `true` if the database is reachable (used by readiness probes). + /// Returns `true` if the database is reachable. pub async fn ping(&self) -> bool { sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() } + /// Checks writer-pool acquisition and query execution against one deadline. + /// + /// Unlike [`Self::ping`], this preserves whether readiness was blocked while + /// borrowing a connection or failed after a connection had been acquired. + /// The query runs on the already-acquired connection so the two phases + /// cannot be collapsed into a second implicit pool acquisition. + pub async fn readiness_check(&self, deadline: tokio::time::Instant) -> DbReadinessOutcome { + self.readiness_check_sql(deadline, "SELECT 1").await + } + + /// Production-bound seam for classifying failures after pool acquisition. + /// Tests vary only the SQL so timeout/error/cancellation paths execute the + /// same acquisition and classification code as [`Self::readiness_check`]. + async fn readiness_check_sql( + &self, + deadline: tokio::time::Instant, + query: &'static str, + ) -> DbReadinessOutcome { + let mut connection = match tokio::time::timeout_at(deadline, self.pool.acquire()).await { + Err(_) => return DbReadinessOutcome::PoolTimeout, + Ok(Err(sqlx::Error::PoolTimedOut)) => return DbReadinessOutcome::PoolTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Postgres readiness pool acquisition failed"); + return DbReadinessOutcome::PoolError; + } + Ok(Ok(connection)) => connection, + }; + + match tokio::time::timeout_at(deadline, sqlx::query(query).execute(&mut *connection)).await + { + Err(_) => DbReadinessOutcome::QueryTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Postgres readiness query failed"); + DbReadinessOutcome::QueryError + } + Ok(Ok(_)) => DbReadinessOutcome::Success, + } + } + /// Returns pool utilisation stats for metrics emission. /// /// `size` — total connections (idle + active) diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index 16580e23f62..3022a15e969 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -4,7 +4,7 @@ use buzz_core::CommunityId; use sqlx::{Connection, PgPool}; use uuid::Uuid; -const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; +const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials async fn setup_db() -> Db { let database_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); @@ -19,6 +19,153 @@ async fn setup_db() -> Db { Db::from_pool(pool) } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_distinguishes_pool_exhaustion_from_success() { + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect size-one readiness test pool"); + let held = pool + .acquire() + .await + .expect("hold the only readiness test connection"); + let db = Db::from_pool(pool); + + let exhausted = db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_millis(25)) + .await; + assert_eq!(exhausted, DbReadinessOutcome::PoolTimeout); + + drop(held); + let recovered = db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; + assert_eq!(recovered, DbReadinessOutcome::Success); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_classifies_closed_pool_query_timeout_and_query_error() { + let database_url = crate::test_support::database_url(); + + let closed_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect closed readiness test pool"); + closed_pool.close().await; + let closed = Db::from_pool(closed_pool) + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await; + assert_eq!(closed, DbReadinessOutcome::PoolError); + + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect query classification test pool"); + let db = Db::from_pool(pool); + + let timed_out = db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_millis(25), + "SELECT pg_sleep(0.2)", + ) + .await; + assert_eq!(timed_out, DbReadinessOutcome::QueryTimeout); + + let query_error = db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_secs(1), + "SELECT 1 / 0", + ) + .await; + assert_eq!(query_error, DbReadinessOutcome::QueryError); + + assert_eq!( + db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await, + DbReadinessOutcome::Success, + "query failures must return the acquired connection to the pool" + ); +} + +#[tokio::test] +#[ignore = "requires Postgres"] +async fn readiness_check_cancellation_balances_waiter_and_inflight_connection() { + let database_url = crate::test_support::database_url(); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("connect cancellation readiness test pool"); + let held = pool + .acquire() + .await + .expect("hold sole connection before waiter cancellation"); + let db = Db::from_pool(pool); + + let waiting_db = db.clone(); + let waiting = tokio::spawn(async move { + waiting_db + .readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(5)) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + waiting.abort(); + assert!(waiting + .await + .expect_err("waiting check must be cancelled") + .is_cancelled()); + drop(held); + + assert_eq!( + db.readiness_check(tokio::time::Instant::now() + std::time::Duration::from_secs(1)) + .await, + DbReadinessOutcome::Success, + "cancelled pool waiter must not consume the released connection" + ); + + let querying_db = db.clone(); + let querying = tokio::spawn(async move { + querying_db + .readiness_check_sql( + tokio::time::Instant::now() + std::time::Duration::from_secs(5), + "SELECT pg_sleep(5)", + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + querying.abort(); + assert!(querying + .await + .expect_err("querying check must be cancelled") + .is_cancelled()); + + let recovered = tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let outcome = db + .readiness_check( + tokio::time::Instant::now() + std::time::Duration::from_millis(250), + ) + .await; + match outcome { + DbReadinessOutcome::Success => break outcome, + DbReadinessOutcome::PoolTimeout => tokio::task::yield_now().await, + unexpected => panic!( + "cancelled in-flight query produced unexpected recovery outcome: {unexpected:?}" + ), + } + } + }) + .await + .expect("cancelled in-flight query must return or replace its connection"); + assert_eq!(recovered, DbReadinessOutcome::Success); +} + async fn make_community(pool: &PgPool) -> Uuid { let id = Uuid::new_v4(); let host = format!("communities-of-channels-{}.example", id.simple()); @@ -399,6 +546,98 @@ async fn drop_scratch_db(admin: &PgPool, pool: PgPool, name: &str) { .await; } +#[tokio::test] +#[ignore = "requires Postgres"] +async fn push_gateway_profile_migration_converges_brownfield_authority() { + let admin = PgPool::connect(&admin_url().await) + .await + .expect("connect admin database"); + let (pool, name) = create_scratch_db_through(&admin, "push_profile", Some(42)).await; + let installation_id = Uuid::new_v4(); + let now = chrono::Utc::now(); + + sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-production', $4, $5, 1, $6)", + ) + .bind(installation_id) + .bind(vec![1_u8]) + .bind(vec![2_u8; 33]) + .bind(vec![3_u8]) + .bind(vec![4_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await + .expect("insert legacy production installation"); + sqlx::query( + "INSERT INTO push_gateway_delegations(\ + id, installation_id, relay_pubkey, endpoint_epoch, generation, not_before, expires_at) \ + VALUES($1, $2, $3, 1, 1, $4, $5)", + ) + .bind(Uuid::new_v4()) + .bind(installation_id) + .bind(vec![5_u8; 32]) + .bind(now) + .bind(now + chrono::Duration::hours(1)) + .execute(&pool) + .await + .expect("insert delegation for legacy installation"); + + migration::run_migrations(&pool) + .await + .expect("apply dogfood-only migration"); + + let legacy_installations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_installations") + .fetch_one(&pool) + .await + .expect("count legacy installations"); + let legacy_delegations: i64 = + sqlx::query_scalar("SELECT count(*) FROM push_gateway_delegations") + .fetch_one(&pool) + .await + .expect("count legacy delegations"); + assert_eq!(legacy_installations, 0); + assert_eq!(legacy_delegations, 0); + + sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-dogfood', $4, $5, 1, $6)", + ) + .bind(Uuid::new_v4()) + .bind(vec![6_u8]) + .bind(vec![7_u8; 33]) + .bind(vec![8_u8]) + .bind(vec![9_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await + .expect("dogfood installation is accepted after migration"); + + let sandbox = sqlx::query( + "INSERT INTO push_gateway_installations(\ + id, app_attest_key_id, app_attest_public_key, assertion_counter, app_profile, \ + token_ciphertext, token_fingerprint, endpoint_epoch, expires_at) \ + VALUES($1, $2, $3, 0, 'buzz-ios-sandbox', $4, $5, 1, $6)", + ) + .bind(Uuid::new_v4()) + .bind(vec![10_u8]) + .bind(vec![11_u8; 33]) + .bind(vec![12_u8]) + .bind(vec![13_u8; 32]) + .bind(now + chrono::Duration::days(1)) + .execute(&pool) + .await; + assert!(sandbox.is_err(), "legacy sandbox profile must be rejected"); + + drop_scratch_db(&admin, pool, &name).await; + admin.close().await; +} + /// Insert identical community + channel rows into a database so the same /// (community, channel) ids resolve in both writer and replica. async fn seed_community_channel( diff --git a/crates/buzz-dev-mcp/src/lib.rs b/crates/buzz-dev-mcp/src/lib.rs index 87c3a119317..d555b6ea542 100644 --- a/crates/buzz-dev-mcp/src/lib.rs +++ b/crates/buzz-dev-mcp/src/lib.rs @@ -39,7 +39,7 @@ impl DevMcp { #[tool( name = "shell", - description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 600000 (10 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)." + description = "Run a shell command (bash by default; set `BUZZ_SHELL` to use cmd, PowerShell, or another shell). Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms defaults to 120000 (2 min) if omitted; capped at 1,200,000 (20 min). For long-running commands (git push with hooks, cargo build, test suites), use 300000+. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and buzz (Buzz relay CLI — run buzz --help for commands)." )] async fn shell( &self, diff --git a/crates/buzz-dev-mcp/src/shell.rs b/crates/buzz-dev-mcp/src/shell.rs index 7aa95b1d879..140d3c44cc9 100644 --- a/crates/buzz-dev-mcp/src/shell.rs +++ b/crates/buzz-dev-mcp/src/shell.rs @@ -14,7 +14,7 @@ use tokio::process::Command; use tokio_util::sync::CancellationToken; const DEFAULT_TIMEOUT_MS: u64 = 120_000; -const MAX_TIMEOUT_MS: u64 = 600_000; +const MAX_TIMEOUT_MS: u64 = 1_200_000; const MAX_COMMAND_BYTES: usize = 1_000_000; const CAPTURE_CAP: usize = 10 * 1024 * 1024; const MAX_BYTES: usize = 50 * 1024; @@ -121,12 +121,16 @@ pub struct ShellParams { pub command: String, #[serde(default)] pub workdir: Option, - /// Defaults to 120000 ms (2 min) if omitted; capped at 600000 ms (10 min). + /// Defaults to 120000 ms (2 min) if omitted; capped at 1,200,000 ms (20 min). /// For long-running commands (git push with hooks, cargo build, test suites), use 300000+. #[serde(default)] pub timeout_ms: Option, } +fn effective_timeout_ms(requested: Option) -> u64 { + requested.unwrap_or(DEFAULT_TIMEOUT_MS).min(MAX_TIMEOUT_MS) +} + pub async fn run( state: &SharedState, p: ShellParams, @@ -138,10 +142,7 @@ pub async fn run( None, )); } - let timeout_ms = p - .timeout_ms - .unwrap_or(DEFAULT_TIMEOUT_MS) - .min(MAX_TIMEOUT_MS); + let timeout_ms = effective_timeout_ms(p.timeout_ms); let workdir: PathBuf = p .workdir .as_deref() @@ -1002,6 +1003,15 @@ mod tests { serde_json::from_str(&text).expect("json") } + #[test] + fn timeout_bounds_preserve_default_and_cap_requests_at_twenty_minutes() { + assert_eq!(effective_timeout_ms(None), 120_000); + assert_eq!(effective_timeout_ms(Some(120_000)), 120_000); + assert_eq!(effective_timeout_ms(Some(1_200_000)), 1_200_000); + assert_eq!(effective_timeout_ms(Some(1_200_001)), 1_200_000); + assert_eq!(effective_timeout_ms(Some(u64::MAX)), 1_200_000); + } + #[tokio::test(flavor = "current_thread")] async fn basic_echo() { let dir = tempdir().expect("tempdir"); diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index e762c14b1e7..123440c0416 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -35,6 +35,7 @@ pub mod nip11; pub mod protocol; /// Durable NIP-PL matcher and delivery worker. pub mod push_runtime; +mod readiness; /// Axum router construction. pub mod router; /// Shared application state. diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index bb8715508e7..260dfaed68b 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -1,5 +1,4 @@ use std::collections::{HashMap, HashSet}; -use std::sync::atomic::Ordering; use std::sync::Arc; use tracing::{error, info, warn}; @@ -1312,7 +1311,7 @@ async fn serve( }); let (shutdown_tx, _) = tokio::sync::watch::channel(false); - let shutdown_flag = Arc::clone(&state.shutting_down); + let shutdown_state = Arc::clone(&state); let drain_conn_manager = Arc::clone(&state.conn_manager); let drain_jitter_ms = state.config.drain_jitter_ms; let tx = shutdown_tx.clone(); @@ -1345,7 +1344,7 @@ async fn serve( // sleeps. Not implemented here. This comment records the plan only. let shutdown_handle = tokio::spawn(async move { shutdown_signal().await; - shutdown_flag.store(true, Ordering::Relaxed); + shutdown_state.begin_shutdown(); info!("Shutdown signal received — readiness now returns 503"); // 5s grace: let K8s stop routing new traffic before we close listeners. tokio::time::sleep(std::time::Duration::from_secs(5)).await; diff --git a/crates/buzz-relay/src/metrics.rs b/crates/buzz-relay/src/metrics.rs index 16e521a44ee..fb484b01742 100644 --- a/crates/buzz-relay/src/metrics.rs +++ b/crates/buzz-relay/src/metrics.rs @@ -32,6 +32,11 @@ const LATENCY_BUCKETS_MS: [f64; 11] = [ /// Seconds-scale buckets for internal processing histograms (event, search, audit). const DURATION_BUCKETS_S: [f64; 10] = [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 5.0]; +/// Readiness buckets concentrate resolution near the two-second failure budget. +const READINESS_DURATION_BUCKETS_S: [f64; 15] = [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0, 2.5, +]; + /// Seconds-scale buckets for Git hydration and pack streams. const GIT_DURATION_BUCKETS_S: [f64; 13] = [ 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, @@ -56,16 +61,8 @@ const GIT_PACK_BUCKETS: [f64; 9] = [0.0, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 1 /// Integer-count buckets for fan-out recipient histograms. const FANOUT_BUCKETS: [f64; 9] = [0.0, 1.0, 5.0, 10.0, 25.0, 50.0, 100.0, 500.0, 1000.0]; -/// Install the global metrics recorder and spawn the Prometheus HTTP exporter. -/// -/// `build()` returns the recorder + exporter future and internally spawns -/// the upkeep task, so no separate upkeep call is needed. -/// -/// Must be called from within a Tokio runtime. -/// Panics if a recorder is already installed or the port is in use. -pub fn install(port: u16, gauge_idle_timeout_secs: u64) { - let (recorder, exporter) = PrometheusBuilder::new() - .with_http_listener(([0, 0, 0, 0], port)) +fn configured_prometheus_builder(gauge_idle_timeout_secs: u64) -> PrometheusBuilder { + PrometheusBuilder::new() // Remove gauge series that the relay intentionally stops emitting. .idle_timeout( MetricKindMask::GAUGE, @@ -102,6 +99,11 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { &GIT_DURATION_BUCKETS_S, ) .expect("valid git compaction duration bucket boundaries") + .set_buckets_for_metric( + Matcher::Full("buzz_readiness_check_duration_seconds".to_owned()), + &READINESS_DURATION_BUCKETS_S, + ) + .expect("valid readiness duration bucket boundaries") .set_buckets_for_metric( Matcher::Full("buzz_git_hydrate_bytes".to_owned()), &GIT_BYTES_BUCKETS, @@ -139,13 +141,57 @@ pub fn install(port: u16, gauge_idle_timeout_secs: u64) { &FANOUT_BUCKETS, ) .expect("valid fanout bucket boundaries") +} + +/// Install the global metrics recorder and spawn the Prometheus HTTP exporter. +/// +/// `build()` returns the recorder + exporter future and internally spawns +/// the upkeep task, so no separate upkeep call is needed. +/// +/// Must be called from within a Tokio runtime. +/// Panics if a recorder is already installed or the port is in use. +pub fn install(port: u16, gauge_idle_timeout_secs: u64) { + let (recorder, exporter) = configured_prometheus_builder(gauge_idle_timeout_secs) + .with_http_listener(([0, 0, 0, 0], port)) .build() .expect("metrics exporter must build exactly once"); metrics::set_global_recorder(recorder).expect("global recorder must be set exactly once"); + describe_readiness_metrics(); tokio::spawn(exporter); } +/// Register the frozen readiness metric descriptions with the active recorder. +pub(crate) fn describe_readiness_metrics() { + metrics::describe_counter!( + "buzz_readiness_checks_total", + "Kubernetes health-listener readiness probes by terminal bounded reason" + ); + metrics::describe_counter!( + "buzz_readiness_dependency_checks_total", + "Completed readiness dependency attempts by dependency and bounded outcome" + ); + metrics::describe_histogram!( + "buzz_readiness_check_duration_seconds", + metrics::Unit::Seconds, + "Completed readiness check duration without outcome label multiplication" + ); + metrics::describe_gauge!( + "buzz_readiness_state", + "Latest publishable readiness state by check, where 1 is ready and 0 is not ready" + ); +} + +#[cfg(test)] +pub(crate) fn readiness_test_recorder() -> ( + metrics_exporter_prometheus::PrometheusRecorder, + metrics_exporter_prometheus::PrometheusHandle, +) { + let recorder = configured_prometheus_builder(300).build_recorder(); + let handle = recorder.handle(); + (recorder, handle) +} + /// Axum middleware that records CAKE framework HTTP metrics. /// /// Emits: diff --git a/crates/buzz-relay/src/readiness.rs b/crates/buzz-relay/src/readiness.rs new file mode 100644 index 00000000000..36a36c228b2 --- /dev/null +++ b/crates/buzz-relay/src/readiness.rs @@ -0,0 +1,836 @@ +//! Readiness dependency evaluation and ordered metrics publication. +//! +//! [`ReadinessCoordinator`] is process-owned. Its mutex is the linearization +//! point shared by health-probe commits and terminal shutdown, so an older +//! evaluation can never overwrite newer gauges or publish ready after shutdown. + +use std::future::Future; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; +use std::time::Duration; + +use buzz_db::{Db, DbReadinessOutcome}; +use tokio::time::Instant; + +const READINESS_TIMEOUT: Duration = Duration::from_secs(2); + +/// Closed label set exported by `buzz_readiness_checks_total{reason}`. +#[cfg(test)] +pub(crate) const READINESS_REASON_LABELS: [&str; 12] = [ + "ready", + "shutting_down", + "postgres_pool_timeout", + "postgres_pool_error", + "postgres_query_timeout", + "postgres_query_error", + "redis_pool_timeout", + "redis_pool_error", + "deletion_catalog_timeout", + "deletion_catalog_error", + "overall_timeout", + "multiple_dependencies_failed", +]; + +/// Maximum raw Prometheus series emitted by readiness for one pod. +/// +/// - 12 overall reasons +/// - 11 valid dependency/outcome pairs (Postgres 5, Redis 3, catalog 3) +/// - 4 histograms x (15 configured buckets + `+Inf` + count + sum) = 72 +/// - 4 current-state gauges +#[cfg(test)] +pub(crate) const READINESS_RAW_SERIES_PER_POD: usize = 12 + 11 + (4 * 18) + 4; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PostgresOutcome { + Success, + PoolTimeout, + PoolError, + QueryTimeout, + QueryError, +} + +impl PostgresOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::PoolTimeout => "pool_timeout", + Self::PoolError => "pool_error", + Self::QueryTimeout => "operation_timeout", + Self::QueryError => "operation_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + matches!(self, Self::PoolTimeout | Self::QueryTimeout) + } +} + +impl From for PostgresOutcome { + fn from(outcome: DbReadinessOutcome) -> Self { + match outcome { + DbReadinessOutcome::Success => Self::Success, + DbReadinessOutcome::PoolTimeout => Self::PoolTimeout, + DbReadinessOutcome::PoolError => Self::PoolError, + DbReadinessOutcome::QueryTimeout => Self::QueryTimeout, + DbReadinessOutcome::QueryError => Self::QueryError, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RedisOutcome { + Success, + PoolTimeout, + PoolError, +} + +impl RedisOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::PoolTimeout => "pool_timeout", + Self::PoolError => "pool_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + self == Self::PoolTimeout + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DeletionCatalogOutcome { + Success, + OperationTimeout, + OperationError, +} + +impl DeletionCatalogOutcome { + fn label(self) -> &'static str { + match self { + Self::Success => "success", + Self::OperationTimeout => "operation_timeout", + Self::OperationError => "operation_error", + } + } + + fn is_success(self) -> bool { + self == Self::Success + } + + fn is_timeout(self) -> bool { + self == Self::OperationTimeout + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReadinessReason { + Ready, + ShuttingDown, + PostgresPoolTimeout, + PostgresPoolError, + PostgresQueryTimeout, + PostgresQueryError, + RedisPoolTimeout, + RedisPoolError, + DeletionCatalogTimeout, + DeletionCatalogError, + OverallTimeout, + MultipleDependenciesFailed, +} + +impl ReadinessReason { + pub(crate) fn label(self) -> &'static str { + match self { + Self::Ready => "ready", + Self::ShuttingDown => "shutting_down", + Self::PostgresPoolTimeout => "postgres_pool_timeout", + Self::PostgresPoolError => "postgres_pool_error", + Self::PostgresQueryTimeout => "postgres_query_timeout", + Self::PostgresQueryError => "postgres_query_error", + Self::RedisPoolTimeout => "redis_pool_timeout", + Self::RedisPoolError => "redis_pool_error", + Self::DeletionCatalogTimeout => "deletion_catalog_timeout", + Self::DeletionCatalogError => "deletion_catalog_error", + Self::OverallTimeout => "overall_timeout", + Self::MultipleDependenciesFailed => "multiple_dependencies_failed", + } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct TimedOutcome { + outcome: O, + duration: Duration, +} + +impl TimedOutcome { + #[cfg(test)] + pub(crate) fn new(outcome: O, duration: Duration) -> Self { + Self { outcome, duration } + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ReadinessEvaluation { + postgres: Option>, + redis: Option>, + deletion_catalog: Option>, + pub(crate) reason: ReadinessReason, + total_duration: Duration, +} + +impl ReadinessEvaluation { + pub(crate) fn shutting_down() -> Self { + Self { + postgres: None, + redis: None, + deletion_catalog: None, + reason: ReadinessReason::ShuttingDown, + total_duration: Duration::ZERO, + } + } + + #[cfg(test)] + pub(crate) fn from_results( + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + total_duration: Duration, + ) -> Self { + Self::for_dependencies(postgres, redis, deletion_catalog, total_duration) + } + + fn for_dependencies( + postgres: TimedOutcome, + redis: TimedOutcome, + deletion_catalog: TimedOutcome, + total_duration: Duration, + ) -> Self { + let reason = final_reason(postgres.outcome, redis.outcome, deletion_catalog.outcome); + Self { + postgres: Some(postgres), + redis: Some(redis), + deletion_catalog: Some(deletion_catalog), + reason, + total_duration, + } + } + + pub(crate) fn is_ready(self) -> bool { + self.reason == ReadinessReason::Ready + } + + pub(crate) fn postgres_ready(self) -> bool { + self.postgres + .is_some_and(|result| result.outcome.is_success()) + } + + pub(crate) fn redis_ready(self) -> bool { + self.redis.is_some_and(|result| result.outcome.is_success()) + } + + pub(crate) fn deletion_catalog_ready(self) -> bool { + self.deletion_catalog + .is_some_and(|result| result.outcome.is_success()) + } + + fn dependencies_ran(self) -> bool { + self.postgres.is_some() || self.redis.is_some() || self.deletion_catalog.is_some() + } +} + +fn final_reason( + postgres: PostgresOutcome, + redis: RedisOutcome, + deletion_catalog: DeletionCatalogOutcome, +) -> ReadinessReason { + let failure_count = usize::from(!postgres.is_success()) + + usize::from(!redis.is_success()) + + usize::from(!deletion_catalog.is_success()); + + if failure_count == 0 { + return ReadinessReason::Ready; + } + if failure_count > 1 { + let all_failures_are_timeouts = (postgres.is_success() || postgres.is_timeout()) + && (redis.is_success() || redis.is_timeout()) + && (deletion_catalog.is_success() || deletion_catalog.is_timeout()); + return if all_failures_are_timeouts { + ReadinessReason::OverallTimeout + } else { + ReadinessReason::MultipleDependenciesFailed + }; + } + + match postgres { + PostgresOutcome::PoolTimeout => ReadinessReason::PostgresPoolTimeout, + PostgresOutcome::PoolError => ReadinessReason::PostgresPoolError, + PostgresOutcome::QueryTimeout => ReadinessReason::PostgresQueryTimeout, + PostgresOutcome::QueryError => ReadinessReason::PostgresQueryError, + PostgresOutcome::Success => match redis { + RedisOutcome::PoolTimeout => ReadinessReason::RedisPoolTimeout, + RedisOutcome::PoolError => ReadinessReason::RedisPoolError, + RedisOutcome::Success => match deletion_catalog { + DeletionCatalogOutcome::OperationTimeout => ReadinessReason::DeletionCatalogTimeout, + DeletionCatalogOutcome::OperationError => ReadinessReason::DeletionCatalogError, + DeletionCatalogOutcome::Success => ReadinessReason::Ready, + }, + }, + } +} + +async fn timed(future: F) -> TimedOutcome +where + F: Future, +{ + let started_at = Instant::now(); + let outcome = future.await; + TimedOutcome { + outcome, + duration: started_at.elapsed(), + } +} + +async fn evaluate_dependencies( + postgres: P, + redis: R, + deletion_catalog: D, +) -> ReadinessEvaluation +where + P: Future, + R: Future, + D: Future, +{ + let started_at = Instant::now(); + let (postgres, redis, deletion_catalog) = + tokio::join!(timed(postgres), timed(redis), timed(deletion_catalog),); + ReadinessEvaluation::for_dependencies(postgres, redis, deletion_catalog, started_at.elapsed()) +} + +async fn redis_check(pool: &deadpool_redis::Pool, deadline: Instant) -> RedisOutcome { + match tokio::time::timeout_at(deadline, pool.get()).await { + Err(_) => RedisOutcome::PoolTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Redis readiness pool acquisition failed"); + RedisOutcome::PoolError + } + Ok(Ok(_connection)) => RedisOutcome::Success, + } +} + +async fn deletion_catalog_check(db: &Db, deadline: Instant) -> DeletionCatalogOutcome { + match tokio::time::timeout_at(deadline, db.validate_deletion_serving_catalog()).await { + Err(_) => DeletionCatalogOutcome::OperationTimeout, + Ok(Err(error)) => { + tracing::debug!(error = %error, "Deletion catalog readiness validation failed"); + DeletionCatalogOutcome::OperationError + } + Ok(Ok(())) => DeletionCatalogOutcome::Success, + } +} + +#[async_trait::async_trait] +pub(crate) trait ReadinessEvaluator: Send + Sync { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation; +} + +struct ProductionReadinessEvaluator; + +#[async_trait::async_trait] +impl ReadinessEvaluator for ProductionReadinessEvaluator { + async fn evaluate(&self, db: &Db, redis_pool: &deadpool_redis::Pool) -> ReadinessEvaluation { + let deadline = Instant::now() + READINESS_TIMEOUT; + evaluate_dependencies( + async { db.readiness_check(deadline).await.into() }, + redis_check(redis_pool, deadline), + deletion_catalog_check(db, deadline), + ) + .await + } +} + +#[derive(Debug, Clone, Copy)] +pub(crate) struct ProbeTicket { + generation: u64, +} + +#[derive(Debug, Clone, Copy)] +pub(crate) enum ProbeStart { + Evaluate(ProbeTicket), + ShuttingDown, +} + +#[derive(Debug, Default)] +struct PublicationState { + next_generation: u64, + latest_published_generation: u64, + shutdown_generation: Option, +} + +/// Serializes readiness result publication with terminal process shutdown. +pub(crate) struct ReadinessCoordinator { + state: Mutex, + evaluator: Arc, +} + +impl Default for ReadinessCoordinator { + fn default() -> Self { + Self { + state: Mutex::new(PublicationState::default()), + evaluator: Arc::new(ProductionReadinessEvaluator), + } + } +} + +impl ReadinessCoordinator { + #[cfg(test)] + pub(crate) fn with_evaluator(evaluator: Arc) -> Self { + Self { + state: Mutex::new(PublicationState::default()), + evaluator, + } + } + + fn lock_state(&self) -> MutexGuard<'_, PublicationState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + pub(crate) async fn evaluate( + &self, + db: &Db, + redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + self.evaluator.evaluate(db, redis_pool).await + } + + /// Allocates a health-probe generation or records a truthful shutdown fast path. + pub(crate) fn begin_probe(&self) -> ProbeStart { + let mut state = self.lock_state(); + if state.shutdown_generation.is_some() { + let evaluation = ReadinessEvaluation::shutting_down(); + record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); + record_overall_state(false); + return ProbeStart::ShuttingDown; + } + + state.next_generation = state.next_generation.saturating_add(1); + ProbeStart::Evaluate(ProbeTicket { + generation: state.next_generation, + }) + } + + /// Commits one completed health probe through the shared publication fence. + pub(crate) fn finish_probe( + &self, + ticket: ProbeTicket, + evaluation: ReadinessEvaluation, + ) -> ReadinessEvaluation { + let mut state = self.lock_state(); + if state.shutdown_generation.is_some() { + record_attempt_metrics(&evaluation, ReadinessReason::ShuttingDown); + return ReadinessEvaluation::shutting_down(); + } + + record_attempt_metrics(&evaluation, evaluation.reason); + if ticket.generation > state.latest_published_generation { + record_current_state(&evaluation); + state.latest_published_generation = ticket.generation; + } + evaluation + } + + /// Returns whether a compatibility/public readiness evaluation may start. + pub(crate) fn public_evaluation_allowed(&self) -> bool { + self.lock_state().shutdown_generation.is_none() + } + + /// Makes shutdown dominate a public request that was already in flight. + pub(crate) fn finish_public_evaluation( + &self, + evaluation: ReadinessEvaluation, + ) -> ReadinessEvaluation { + if self.lock_state().shutdown_generation.is_some() { + ReadinessEvaluation::shutting_down() + } else { + evaluation + } + } + + /// Commits terminal shutdown and immediately publishes overall not-ready. + pub(crate) fn begin_shutdown(&self) { + let mut state = self.lock_state(); + if state.shutdown_generation.is_none() { + let generation = state.next_generation.saturating_add(1); + state.shutdown_generation = Some(generation); + record_overall_state(false); + } + } +} + +fn record_attempt_metrics(evaluation: &ReadinessEvaluation, reason: ReadinessReason) { + metrics::counter!( + "buzz_readiness_checks_total", + "reason" => reason.label(), + ) + .increment(1); + + if !evaluation.dependencies_ran() { + return; + } + + metrics::histogram!( + "buzz_readiness_check_duration_seconds", + "check" => "overall", + ) + .record(evaluation.total_duration.as_secs_f64()); + + if let Some(result) = evaluation.postgres { + record_dependency_attempt("postgres", result.outcome.label(), result.duration); + } + if let Some(result) = evaluation.redis { + record_dependency_attempt("redis", result.outcome.label(), result.duration); + } + if let Some(result) = evaluation.deletion_catalog { + record_dependency_attempt("deletion_catalog", result.outcome.label(), result.duration); + } +} + +fn record_dependency_attempt(dependency: &'static str, outcome: &'static str, duration: Duration) { + metrics::counter!( + "buzz_readiness_dependency_checks_total", + "dependency" => dependency, + "outcome" => outcome, + ) + .increment(1); + metrics::histogram!( + "buzz_readiness_check_duration_seconds", + "check" => dependency, + ) + .record(duration.as_secs_f64()); +} + +fn record_current_state(evaluation: &ReadinessEvaluation) { + record_overall_state(evaluation.is_ready()); + if let Some(result) = evaluation.postgres { + record_dependency_state("postgres", result.outcome.is_success()); + } + if let Some(result) = evaluation.redis { + record_dependency_state("redis", result.outcome.is_success()); + } + if let Some(result) = evaluation.deletion_catalog { + record_dependency_state("deletion_catalog", result.outcome.is_success()); + } +} + +fn record_overall_state(ready: bool) { + metrics::gauge!("buzz_readiness_state", "check" => "overall").set(if ready { + 1.0 + } else { + 0.0 + }); +} + +fn record_dependency_state(dependency: &'static str, ready: bool) { + metrics::gauge!("buzz_readiness_state", "check" => dependency).set(if ready { + 1.0 + } else { + 0.0 + }); +} + +#[cfg(test)] +mod tests { + use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use metrics_util::CompositeKey; + + use super::*; + + fn ready_evaluation() -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), + TimedOutcome::new(RedisOutcome::Success, Duration::from_millis(10)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), + Duration::from_millis(35), + ) + } + + fn redis_failure_evaluation() -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + TimedOutcome::new(PostgresOutcome::Success, Duration::from_millis(35)), + TimedOutcome::new(RedisOutcome::PoolTimeout, Duration::from_secs(2)), + TimedOutcome::new(DeletionCatalogOutcome::Success, Duration::from_millis(20)), + Duration::from_secs(2), + ) + } + + fn exact_metric<'a>( + snapshot: &'a [( + CompositeKey, + Option, + Option, + DebugValue, + )], + name: &str, + labels: &[(&str, &str)], + ) -> Option<&'a DebugValue> { + snapshot.iter().find_map(|(key, _, _, value)| { + let actual = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|expected| actual.contains(expected))) + .then_some(value) + }) + } + + fn gauge_value( + snapshot: &[( + CompositeKey, + Option, + Option, + DebugValue, + )], + check: &str, + ) -> f64 { + let value = exact_metric(snapshot, "buzz_readiness_state", &[("check", check)]) + .expect("readiness gauge"); + let DebugValue::Gauge(value) = value else { + panic!("readiness state must be a gauge"); + }; + value.into_inner() + } + + #[tokio::test(start_paused = true)] + async fn evaluation_preserves_a_completed_check_when_another_times_out() { + let evaluation = evaluate_dependencies( + async { + tokio::time::sleep(Duration::from_millis(35)).await; + PostgresOutcome::Success + }, + async { + tokio::time::sleep(Duration::from_secs(2)).await; + RedisOutcome::PoolTimeout + }, + async { + tokio::time::sleep(Duration::from_millis(10)).await; + DeletionCatalogOutcome::Success + }, + ) + .await; + + assert_eq!(evaluation.reason, ReadinessReason::RedisPoolTimeout); + assert_eq!( + evaluation.postgres.map(|result| result.duration), + Some(Duration::from_millis(35)) + ); + assert_eq!( + evaluation.redis.map(|result| result.duration), + Some(Duration::from_secs(2)) + ); + } + + #[test] + fn simultaneous_dependency_timeouts_are_an_overall_timeout() { + assert_eq!( + final_reason( + PostgresOutcome::PoolTimeout, + RedisOutcome::PoolTimeout, + DeletionCatalogOutcome::Success, + ), + ReadinessReason::OverallTimeout + ); + } + + #[test] + fn dependency_types_expose_only_valid_outcome_pairs() { + assert_eq!( + [ + PostgresOutcome::Success, + PostgresOutcome::PoolTimeout, + PostgresOutcome::PoolError, + PostgresOutcome::QueryTimeout, + PostgresOutcome::QueryError, + ] + .map(PostgresOutcome::label), + [ + "success", + "pool_timeout", + "pool_error", + "operation_timeout", + "operation_error", + ] + ); + assert_eq!( + [ + RedisOutcome::Success, + RedisOutcome::PoolTimeout, + RedisOutcome::PoolError, + ] + .map(RedisOutcome::label), + ["success", "pool_timeout", "pool_error"] + ); + assert_eq!( + [ + DeletionCatalogOutcome::Success, + DeletionCatalogOutcome::OperationTimeout, + DeletionCatalogOutcome::OperationError, + ] + .map(DeletionCatalogOutcome::label), + ["success", "operation_timeout", "operation_error"] + ); + assert_eq!(READINESS_RAW_SERIES_PER_POD, 99); + } + + #[test] + fn slow_older_failure_cannot_overwrite_newer_success_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { + panic!("serving probe A"); + }; + let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { + panic!("serving probe B"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + coordinator.finish_probe(fast_b, ready_evaluation()); + coordinator.finish_probe(slow_a, redis_failure_evaluation()); + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&snapshot, "overall"), 1.0); + assert_eq!(gauge_value(&snapshot, "redis"), 1.0); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_checks_total", + &[("reason", "ready")] + ), + Some(DebugValue::Counter(1)) + )); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_checks_total", + &[("reason", "redis_pool_timeout")] + ), + Some(DebugValue::Counter(1)) + )); + } + + #[test] + fn slow_older_success_cannot_overwrite_newer_failure_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(slow_a) = coordinator.begin_probe() else { + panic!("serving probe A"); + }; + let ProbeStart::Evaluate(fast_b) = coordinator.begin_probe() else { + panic!("serving probe B"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + coordinator.finish_probe(fast_b, redis_failure_evaluation()); + coordinator.finish_probe(slow_a, ready_evaluation()); + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert_eq!(gauge_value(&snapshot, "postgres"), 1.0); + assert_eq!(gauge_value(&snapshot, "redis"), 0.0); + assert_eq!(gauge_value(&snapshot, "deletion_catalog"), 1.0); + } + + #[test] + fn shutdown_fast_path_preserves_dependency_state_and_histograms() { + let coordinator = ReadinessCoordinator::default(); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { + panic!("initial serving probe"); + }; + coordinator.finish_probe(ticket, ready_evaluation()); + coordinator.begin_shutdown(); + assert!(matches!( + coordinator.begin_probe(), + ProbeStart::ShuttingDown + )); + }); + let after = snapshotter.snapshot().into_vec(); + + for dependency in ["postgres", "redis", "deletion_catalog"] { + assert_eq!( + gauge_value(&after, dependency), + 1.0, + "shutdown must not fabricate {dependency} state" + ); + } + for check in ["overall", "postgres", "redis", "deletion_catalog"] { + assert!( + matches!( + exact_metric( + &after, + "buzz_readiness_check_duration_seconds", + &[("check", check)] + ), + Some(DebugValue::Histogram(values)) if values.len() == 1 + ), + "shutdown fast path must not add a {check} duration" + ); + } + assert_eq!(gauge_value(&after, "overall"), 0.0); + assert!(matches!( + exact_metric( + &after, + "buzz_readiness_checks_total", + &[("reason", "shutting_down")] + ), + Some(DebugValue::Counter(1)) + )); + } + + #[test] + fn shutdown_dominates_an_in_flight_success_without_resurrecting_gauges() { + let coordinator = ReadinessCoordinator::default(); + let ProbeStart::Evaluate(ticket) = coordinator.begin_probe() else { + panic!("serving probe"); + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let response = metrics::with_local_recorder(&recorder, || { + coordinator.begin_shutdown(); + coordinator.finish_probe(ticket, ready_evaluation()) + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(response.reason, ReadinessReason::ShuttingDown); + assert_eq!(gauge_value(&snapshot, "overall"), 0.0); + assert!( + exact_metric(&snapshot, "buzz_readiness_state", &[("check", "postgres")]).is_none() + ); + assert!(matches!( + exact_metric( + &snapshot, + "buzz_readiness_dependency_checks_total", + &[("dependency", "postgres"), ("outcome", "success")] + ), + Some(DebugValue::Counter(1)) + )); + } +} diff --git a/crates/buzz-relay/src/router.rs b/crates/buzz-relay/src/router.rs index dd0fde6fdcd..61aedf70be0 100644 --- a/crates/buzz-relay/src/router.rs +++ b/crates/buzz-relay/src/router.rs @@ -24,6 +24,7 @@ use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; use crate::nip11::{nip11_document, relay_info_handler}; +use crate::readiness::{self, ReadinessEvaluation, ReadinessReason}; use crate::state::AppState; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. @@ -67,7 +68,7 @@ pub fn build_router(state: Arc) -> Router { // Health endpoints .route("/health", get(health_handler)) .route("/_liveness", get(liveness_handler)) - .route("/_readiness", get(readiness_handler)) + .route("/_readiness", get(public_readiness_handler)) // Nostr HTTP bridge (NIP-98 auth) .route("/events", post(api::bridge::submit_event)) .route("/query", post(api::bridge::query_events)) @@ -294,7 +295,7 @@ async fn admin_spa_document(state: &AppState, accept: &str) -> axum::response::R pub fn build_health_router(state: Arc) -> Router { Router::new() .route("/_liveness", get(liveness_handler)) - .route("/_readiness", get(readiness_handler)) + .route("/_readiness", get(kubernetes_readiness_handler)) .route("/_status", get(status_handler)) .route("/_mesh", get(mesh_status_handler)) .with_state(state) @@ -406,11 +407,36 @@ async fn liveness_handler() -> impl IntoResponse { (StatusCode::OK, "ok") } -/// Readiness probe — checks shutdown flag, Postgres, and Redis connectivity. -async fn readiness_handler(State(state): State>) -> impl IntoResponse { - use std::time::Duration; +/// Compatibility endpoint on the public listener. It evaluates dependencies +/// and preserves the existing response contract but never records rollout +/// telemetry. +async fn public_readiness_handler(State(state): State>) -> impl IntoResponse { + if !state.readiness.public_evaluation_allowed() { + return readiness_response(ReadinessEvaluation::shutting_down(), false); + } + + let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; + let evaluation = state.readiness.finish_public_evaluation(evaluation); + readiness_response(evaluation, false) +} + +/// Kubernetes health-listener endpoint. All rollout metrics flow through the +/// process-owned coordinator so shutdown and probe generations are ordered. +async fn kubernetes_readiness_handler(State(state): State>) -> impl IntoResponse { + let readiness::ProbeStart::Evaluate(ticket) = state.readiness.begin_probe() else { + return readiness_response(ReadinessEvaluation::shutting_down(), true); + }; + + let evaluation = state.readiness.evaluate(&state.db, &state.redis_pool).await; + let evaluation = state.readiness.finish_probe(ticket, evaluation); + readiness_response(evaluation, true) +} - if state.shutting_down.load(Ordering::Relaxed) { +fn readiness_response( + evaluation: ReadinessEvaluation, + include_reason: bool, +) -> axum::response::Response { + if evaluation.reason == ReadinessReason::ShuttingDown { return ( StatusCode::SERVICE_UNAVAILABLE, Json(json!({"status": "shutting_down"})), @@ -418,33 +444,23 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo .into_response(); } - let check = async { - let (pg_ok, redis_ok, deletion_catalog_ok) = tokio::join!( - state.db.ping(), - async { state.redis_pool.get().await.is_ok() }, - async { state.db.validate_deletion_serving_catalog().await.is_ok() }, - ); - (pg_ok, redis_ok, deletion_catalog_ok) - }; - - let (pg_ok, redis_ok, deletion_catalog_ok) = - tokio::time::timeout(Duration::from_secs(2), check) - .await - .unwrap_or((false, false, false)); + let pg_ok = evaluation.postgres_ready(); + let redis_ok = evaluation.redis_ready(); + let deletion_catalog_ok = evaluation.deletion_catalog_ready(); - if pg_ok && redis_ok && deletion_catalog_ok { + if evaluation.is_ready() { (StatusCode::OK, Json(json!({"status": "ready"}))).into_response() } else { - ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({ - "status": "not_ready", - "postgres": pg_ok, - "redis": redis_ok, - "deletion_catalog": deletion_catalog_ok - })), - ) - .into_response() + let mut payload = json!({ + "status": "not_ready", + "postgres": pg_ok, + "redis": redis_ok, + "deletion_catalog": deletion_catalog_ok + }); + if include_reason { + payload["reason"] = json!(evaluation.reason.label()); + } + (StatusCode::SERVICE_UNAVAILABLE, Json(payload)).into_response() } } @@ -506,12 +522,17 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { #[cfg(test)] mod tests { + use std::collections::VecDeque; + use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; + use std::sync::{Mutex, PoisonError}; + use std::time::Duration; + use axum::{routing::get, Router}; use futures_util::SinkExt; use opentelemetry::trace::TracerProvider as _; use opentelemetry_sdk::trace::{InMemorySpanExporter, SdkTracerProvider}; use tokio::net::TcpListener; - use tokio::sync::mpsc; + use tokio::sync::{mpsc, Notify}; use tokio_tungstenite::{connect_async, tungstenite::Message}; use tower::ServiceBuilder; use tracing::Instrument as _; @@ -519,6 +540,98 @@ mod tests { use super::*; + struct ScriptedReadinessEvaluator { + evaluations: Mutex>, + } + + impl ScriptedReadinessEvaluator { + fn new(evaluations: impl IntoIterator) -> Self { + Self { + evaluations: Mutex::new(evaluations.into_iter().collect()), + } + } + + fn push(&self, evaluation: ReadinessEvaluation) { + self.evaluations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push_back(evaluation); + } + } + + #[async_trait::async_trait] + impl readiness::ReadinessEvaluator for ScriptedReadinessEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + self.evaluations + .lock() + .unwrap_or_else(PoisonError::into_inner) + .pop_front() + .expect("scripted readiness evaluation") + } + } + + struct BarrierReadinessEvaluator { + calls: AtomicUsize, + first_started: Notify, + release_first: Notify, + first: ReadinessEvaluation, + second: ReadinessEvaluation, + } + + impl BarrierReadinessEvaluator { + fn new(first: ReadinessEvaluation, second: ReadinessEvaluation) -> Self { + Self { + calls: AtomicUsize::new(0), + first_started: Notify::new(), + release_first: Notify::new(), + first, + second, + } + } + } + + #[async_trait::async_trait] + impl readiness::ReadinessEvaluator for BarrierReadinessEvaluator { + async fn evaluate( + &self, + _db: &buzz_db::Db, + _redis_pool: &deadpool_redis::Pool, + ) -> ReadinessEvaluation { + if self.calls.fetch_add(1, AtomicOrdering::SeqCst) == 0 { + self.first_started.notify_waiters(); + self.release_first.notified().await; + self.first + } else { + self.second + } + } + } + + fn readiness_evaluation( + postgres: readiness::PostgresOutcome, + redis: readiness::RedisOutcome, + deletion_catalog: readiness::DeletionCatalogOutcome, + ) -> ReadinessEvaluation { + ReadinessEvaluation::from_results( + readiness::TimedOutcome::new(postgres, Duration::from_millis(35)), + readiness::TimedOutcome::new(redis, Duration::from_millis(20)), + readiness::TimedOutcome::new(deletion_catalog, Duration::from_millis(15)), + Duration::from_millis(35), + ) + } + + fn ready_evaluation() -> ReadinessEvaluation { + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ) + } + #[test] fn invite_landing_path_requires_exactly_one_nonempty_code_segment() { assert!(is_invite_landing_path("/invite/payload.mac")); @@ -594,6 +707,447 @@ mod tests { Arc::new(state) } + async fn readiness_state(evaluator: Arc) -> Arc { + let mut config = crate::config::Config::from_env().expect("default config loads"); + config.require_relay_membership = false; + config.database_url = "postgres://buzz:buzz_dev@127.0.0.1:1/buzz".to_string(); + config.redis_url = "redis://127.0.0.1:1".to_string(); + let pool = sqlx::PgPool::connect_lazy(&config.database_url).expect("lazy pg pool"); + let db = buzz_db::Db::from_pool(pool.clone()); + let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) + .create_pool(Some(deadpool_redis::Runtime::Tokio1)) + .expect("redis pool"); + let pubsub = Arc::new( + buzz_pubsub::PubSubManager::new(&config.redis_url, redis_pool.clone()) + .await + .expect("pubsub manager"), + ); + let audit = buzz_audit::AuditService::new(pool.clone()); + let auth = buzz_auth::AuthService::new(config.auth.clone()); + let search = buzz_search::SearchService::new(pool.clone()); + let workflow_engine = Arc::new(buzz_workflow::WorkflowEngine::new( + db.clone(), + buzz_workflow::WorkflowConfig::default(), + )); + let media_storage = buzz_media::MediaStorage::new(&config.media).expect("media storage"); + let (mut state, _audit_shutdown) = AppState::new( + config, + db, + redis_pool, + audit, + pubsub, + auth, + search, + workflow_engine, + nostr::Keys::generate(), + media_storage, + ); + state.set_readiness_evaluator(evaluator); + Arc::new(state) + } + + async fn readiness_request(router: Router) -> (StatusCode, serde_json::Value) { + let response = router + .oneshot( + Request::get("/_readiness") + .body(Body::empty()) + .expect("readiness request"), + ) + .await + .expect("readiness response"); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("readiness response body"); + let payload = serde_json::from_slice(&body).expect("readiness JSON"); + (status, payload) + } + + fn readiness_metric_lines(rendered: &str) -> Vec<&str> { + rendered + .lines() + .filter(|line| line.starts_with("buzz_readiness")) + .collect() + } + + fn sorted_readiness_metric_lines(rendered: &str) -> Vec { + let mut lines = readiness_metric_lines(rendered) + .into_iter() + .map(str::to_owned) + .collect::>(); + lines.sort(); + lines + } + + fn metric_value(rendered: &str, exact_prefix: &str) -> f64 { + rendered + .lines() + .find_map(|line| { + line.strip_prefix(exact_prefix) + .and_then(|value| value.strip_prefix(' ')) + .and_then(|value| value.parse().ok()) + }) + .unwrap_or_else(|| panic!("missing metric line: {exact_prefix}")) + } + + #[test] + fn production_readiness_routes_export_the_frozen_health_only_contract() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(ScriptedReadinessEvaluator::new(std::iter::repeat_n( + ready_evaluation(), + 4, + ))); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + crate::metrics::describe_readiness_metrics(); + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let public = build_router(state.clone()); + let health = build_health_router(state.clone()); + + for _ in 0..3 { + assert_eq!( + readiness_request(public.clone()).await, + (StatusCode::OK, json!({"status": "ready"})) + ); + } + assert!( + readiness_metric_lines(&handle.render()).is_empty(), + "public compatibility requests must emit no readiness series" + ); + + assert_eq!( + readiness_request(health.clone()).await, + (StatusCode::OK, json!({"status": "ready"})) + ); + let first_scrape = handle.render(); + + assert!(first_scrape.contains("# TYPE buzz_readiness_checks_total counter")); + assert!(first_scrape + .contains("# TYPE buzz_readiness_dependency_checks_total counter")); + assert!(first_scrape + .contains("# TYPE buzz_readiness_check_duration_seconds histogram")); + assert!(first_scrape.contains("# TYPE buzz_readiness_state gauge")); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_checks_total{reason=\"ready\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &first_scrape, + "buzz_readiness_state{check=\"overall\"}" + ), + 1.0 + ); + for bucket in ["2", "2.5", "+Inf"] { + assert!(first_scrape.contains(&format!( + "buzz_readiness_check_duration_seconds_bucket{{check=\"overall\",le=\"{bucket}\"}}" + ))); + } + assert!(!first_scrape.contains("result=")); + assert!(!first_scrape + .lines() + .filter(|line| line.starts_with("buzz_readiness_check_duration_seconds")) + .any(|line| line.contains("outcome="))); + + let before_public_failure = sorted_readiness_metric_lines(&first_scrape); + evaluator.push(readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + )); + assert_eq!( + readiness_request(public.clone()).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({ + "status": "not_ready", + "postgres": true, + "redis": false, + "deletion_catalog": true + }) + ) + ); + assert_eq!( + sorted_readiness_metric_lines(&handle.render()), + before_public_failure + ); + + let contract_evaluations = [ + readiness_evaluation( + readiness::PostgresOutcome::PoolTimeout, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolError, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::QueryTimeout, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::QueryError, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::Success, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::OperationTimeout, + ), + readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::Success, + readiness::DeletionCatalogOutcome::OperationError, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolTimeout, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::OperationTimeout, + ), + readiness_evaluation( + readiness::PostgresOutcome::PoolError, + readiness::RedisOutcome::PoolError, + readiness::DeletionCatalogOutcome::Success, + ), + ]; + for evaluation in contract_evaluations { + evaluator.push(evaluation); + let (status, payload) = readiness_request(health.clone()).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(payload["reason"], json!(evaluation.reason.label())); + } + + let before_shutdown = handle.render(); + let histogram_counts_before = ["overall", "postgres", "redis", "deletion_catalog"] + .map(|check| { + metric_value( + &before_shutdown, + &format!( + "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" + ), + ) + }); + state.begin_shutdown(); + assert_eq!( + readiness_request(public).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + let after_public_shutdown = handle.render(); + assert!(after_public_shutdown + .lines() + .all(|line| !line.contains("reason=\"shutting_down\""))); + + assert_eq!( + readiness_request(health).await, + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + let final_scrape = handle.render(); + let histogram_counts_after = ["overall", "postgres", "redis", "deletion_catalog"] + .map(|check| { + metric_value( + &final_scrape, + &format!( + "buzz_readiness_check_duration_seconds_count{{check=\"{check}\"}}" + ), + ) + }); + assert_eq!(histogram_counts_after, histogram_counts_before); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_checks_total{reason=\"shutting_down\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &final_scrape, + "buzz_readiness_state{check=\"overall\"}" + ), + 0.0 + ); + assert!(!final_scrape.contains("sensitive-sql-or-url")); + + let exported_reasons = final_scrape + .lines() + .filter(|line| line.starts_with("buzz_readiness_checks_total{")) + .count(); + assert_eq!(exported_reasons, readiness::READINESS_REASON_LABELS.len()); + assert_eq!( + readiness_metric_lines(&final_scrape).len(), + readiness::READINESS_RAW_SERIES_PER_POD, + "readiness series contract must stay at or below its 99-series cap" + ); + }); + }); + } + + fn run_out_of_order_route_case( + first: ReadinessEvaluation, + second: ReadinessEvaluation, + ) -> (serde_json::Value, serde_json::Value, String) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(BarrierReadinessEvaluator::new(first, second)); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state); + let first_started = evaluator.first_started.notified(); + let slow_first = tokio::spawn(readiness_request(health.clone())); + first_started.await; + + let (_, second_payload) = readiness_request(health).await; + evaluator.release_first.notify_one(); + let (_, first_payload) = slow_first.await.expect("slow first probe task"); + (first_payload, second_payload, handle.render()) + }) + }) + } + + #[test] + fn real_health_route_generation_fence_covers_both_completion_orders() { + let failure = readiness_evaluation( + readiness::PostgresOutcome::Success, + readiness::RedisOutcome::PoolTimeout, + readiness::DeletionCatalogOutcome::Success, + ); + + let (older_failure, newer_success, success_scrape) = + run_out_of_order_route_case(failure, ready_evaluation()); + assert_eq!(older_failure["reason"], json!("redis_pool_timeout")); + assert_eq!(newer_success, json!({"status": "ready"})); + assert_eq!( + metric_value(&success_scrape, "buzz_readiness_state{check=\"overall\"}"), + 1.0 + ); + assert_eq!( + metric_value(&success_scrape, "buzz_readiness_state{check=\"redis\"}"), + 1.0 + ); + + let (older_success, newer_failure, failure_scrape) = + run_out_of_order_route_case(ready_evaluation(), failure); + assert_eq!(older_success, json!({"status": "ready"})); + assert_eq!(newer_failure["reason"], json!("redis_pool_timeout")); + assert_eq!( + metric_value(&failure_scrape, "buzz_readiness_state{check=\"overall\"}"), + 0.0 + ); + assert_eq!( + metric_value(&failure_scrape, "buzz_readiness_state{check=\"redis\"}"), + 0.0 + ); + for scrape in [&success_scrape, &failure_scrape] { + assert_eq!( + metric_value(scrape, "buzz_readiness_checks_total{reason=\"ready\"}"), + 1.0 + ); + assert_eq!( + metric_value( + scrape, + "buzz_readiness_checks_total{reason=\"redis_pool_timeout\"}" + ), + 1.0 + ); + } + } + + #[test] + fn real_health_route_shutdown_fence_dominates_an_in_flight_success() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("current-thread runtime"); + let evaluator = Arc::new(BarrierReadinessEvaluator::new( + ready_evaluation(), + ready_evaluation(), + )); + let (recorder, handle) = crate::metrics::readiness_test_recorder(); + + metrics::with_local_recorder(&recorder, || { + runtime.block_on(async { + let state = readiness_state(evaluator.clone()).await; + let health = build_health_router(state.clone()); + let first_started = evaluator.first_started.notified(); + let in_flight = tokio::spawn(readiness_request(health)); + first_started.await; + + state.begin_shutdown(); + evaluator.release_first.notify_one(); + assert_eq!( + in_flight.await.expect("in-flight readiness task"), + ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"status": "shutting_down"}) + ) + ); + + let scrape = handle.render(); + assert_eq!( + metric_value(&scrape, "buzz_readiness_state{check=\"overall\"}"), + 0.0 + ); + assert!(scrape + .lines() + .all(|line| !line.starts_with("buzz_readiness_state{check=\"postgres\"}"))); + assert_eq!( + metric_value( + &scrape, + "buzz_readiness_checks_total{reason=\"shutting_down\"}" + ), + 1.0 + ); + assert_eq!( + metric_value( + &scrape, + "buzz_readiness_dependency_checks_total{dependency=\"postgres\",outcome=\"success\"}" + ), + 1.0 + ); + }); + }); + } + /// A minimal built SPA: an index document, one hashed asset, and the /// root-level favicon Vite copies out of `public/`. fn write_bundle(dir: &std::path::Path) { diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index a3fc4772284..5b7f3e411e2 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -802,6 +802,8 @@ pub struct AppState { pub audio_rooms: Arc, /// Set to `true` on SIGTERM — readiness probe returns 503. pub shutting_down: Arc, + /// Orders readiness gauge publication against terminal shutdown. + pub(crate) readiness: Arc, /// Process start time — used by `/_status` endpoint. pub started_at: Instant, /// Shared, community-scoped NIP-98 replay prevention. @@ -1003,6 +1005,7 @@ impl AppState { git_pack_cache, audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), + readiness: Arc::new(crate::readiness::ReadinessCoordinator::default()), started_at: Instant::now(), nip98_replay, gif_http_client, @@ -1044,6 +1047,23 @@ impl AppState { ) } + /// Atomically closes readiness publication before exposing shutdown to + /// the relay's other fast-path lifecycle checks. + pub fn begin_shutdown(&self) { + self.readiness.begin_shutdown(); + self.shutting_down.store(true, Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn set_readiness_evaluator( + &mut self, + evaluator: Arc, + ) { + self.readiness = Arc::new(crate::readiness::ReadinessCoordinator::with_evaluator( + evaluator, + )); + } + /// Inter-relay mesh handle. `None` ⇒ mesh-off / single-instance: callers /// must no-op to today's behavior. Set once by `main.rs` after boot. pub fn mesh(&self) -> Option<&crate::mesh_boot::MeshHandle> { diff --git a/deploy/charts/buzz-push-gateway/Chart.yaml b/deploy/charts/buzz-push-gateway/Chart.yaml index 4035fdce35b..fe302e58c28 100644 --- a/deploy/charts/buzz-push-gateway/Chart.yaml +++ b/deploy/charts/buzz-push-gateway/Chart.yaml @@ -3,6 +3,6 @@ apiVersion: v2 # branches (see docs/push-gateway-deployment.md, "Gateway chart release"). name: buzz-push-gateway description: Public capability-gated APNs last-hop gateway for Buzz -version: 0.1.0 +version: 0.2.0 appVersion: "0.1.0" type: application diff --git a/deploy/charts/buzz-push-gateway/templates/deployment.yaml b/deploy/charts/buzz-push-gateway/templates/deployment.yaml index 20ce7567270..ecdc97582af 100644 --- a/deploy/charts/buzz-push-gateway/templates/deployment.yaml +++ b/deploy/charts/buzz-push-gateway/templates/deployment.yaml @@ -11,6 +11,9 @@ spec: template: metadata: labels: {{- include "push.runtimeLabels" . | nindent 8 }} + {{- with .Values.podAnnotations }} + annotations: {{- toYaml . | nindent 8 }} + {{- end }} spec: automountServiceAccountToken: false terminationGracePeriodSeconds: 60 diff --git a/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml b/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml new file mode 100644 index 00000000000..6a02fdca4c5 --- /dev/null +++ b/deploy/charts/buzz-push-gateway/tests/datadog-values.yaml @@ -0,0 +1,30 @@ +# Render-only fixture proving Datadog Autodiscovery can scrape the private +# metrics listener without installing prometheus-operator CRDs. Deployment +# repositories must replace these illustrative selectors with their agent's +# actual namespace and pod labels. +podAnnotations: + ad.datadoghq.com/gateway.checks: | + { + "openmetrics": { + "init_config": {}, + "instances": [ + { + "openmetrics_endpoint": "http://%%host%%:8081/metrics", + "service": "buzz-push-gateway", + "namespace": "block.buzz_push_gateway", + "metrics": ["push_gateway_.*"], + "histogram_buckets_as_distributions": true, + "send_distribution_buckets": true, + "send_monotonic_counter": true, + "collect_counters_with_distributions": true + } + ] + } + } +networkPolicy: + monitoring: + enabled: true + namespaceSelector: + kubernetes.io/metadata.name: datadog + podSelector: + app.kubernetes.io/name: datadog-agent diff --git a/deploy/charts/buzz-push-gateway/tests/release-contract.sh b/deploy/charts/buzz-push-gateway/tests/release-contract.sh index 993c4c05369..eb445687fa9 100755 --- a/deploy/charts/buzz-push-gateway/tests/release-contract.sh +++ b/deploy/charts/buzz-push-gateway/tests/release-contract.sh @@ -3,10 +3,21 @@ set -euo pipefail env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml <<'RUBY' auto_text = File.read('.github/workflows/auto-tag-on-release-pr-merge.yml') publish_text = File.read('.github/workflows/push-gateway-helm-chart.yml') +deployment_text = File.read('docs/push-gateway-deployment.md') +chart = YAML.load_file('deploy/charts/buzz-push-gateway/Chart.yaml') # Parse first, then pin the tag producer and consumer strings whose agreement # makes this a reachable lane rather than an orphan publisher. YAML.load(auto_text) YAML.load(publish_text) +version = chart.fetch('version').to_s +raise "gateway chart version is not semver: #{version}" unless version.match?(/\A\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?\z/) +workspace_package = File.read('Cargo.toml').match(/\[workspace\.package\](.*?)(?=\n\[|\z)/m) +raise "workspace package metadata is missing" unless workspace_package +binary_version = workspace_package[1].match(/^version\s*=\s*"([^"]+)"/)&.[](1) +raise "workspace package version is missing" unless binary_version +unless chart.fetch('appVersion').to_s == binary_version + raise "gateway chart appVersion does not match packaged binary #{binary_version}" +end [ 'push-chart-release/*)', 'VERSION="${BRANCH#push-chart-release/}"', @@ -26,4 +37,14 @@ end ].each do |needle| raise "missing gateway chart publisher contract: #{needle}" unless publish_text.include?(needle) end +[ + 'inspect and fetch the published chart version', + 'helm show chart oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z', + 'helm pull oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z', +].each do |needle| + raise "missing gateway chart retrieval guidance: #{needle}" unless deployment_text.include?(needle) +end +if deployment_text.include?('verify the immutable chart artifact') + raise 'gateway chart retrieval guidance overstates authenticity verification' +end RUBY diff --git a/deploy/charts/buzz-push-gateway/tests/render.sh b/deploy/charts/buzz-push-gateway/tests/render.sh index 250955c5fc2..97568ba2d0c 100755 --- a/deploy/charts/buzz-push-gateway/tests/render.sh +++ b/deploy/charts/buzz-push-gateway/tests/render.sh @@ -1,25 +1,32 @@ #!/usr/bin/env bash set -euo pipefail -out=$(mktemp); production_out=$(mktemp) -trap 'rm -f "$out" "$production_out"' EXIT +out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mktemp) +trap 'rm -f "$out" "$production_out" "$route_out" "$datadog_out" "${monitoring_out:-}"' EXIT # Defaults must lint and render without parameter injection. helm lint deploy/charts/buzz-push-gateway >/dev/null helm template push deploy/charts/buzz-push-gateway >"$out" -# Production values must attach push.buzz.xyz to an explicit Gateway. +# Production values support a platform-owned ingress without rendering an +# HTTPRoute. The environment-owned inputs remain mandatory. production_args=( -f deploy/charts/buzz-push-gateway/values-production.yaml --set 'image.digest=sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' --set 'profiles.dogfood.appAttestAppId=REALTEAM.xyz.block.buzz.dogfood.mobile' - --set 'httpRoute.parentRefs[0].name=production-gateway' - --set 'httpRoute.parentRefs[0].namespace=gateway-system' --set 'networkPolicy.postgresEgressCidrs[0]=10.42.0.0/16' ) helm lint deploy/charts/buzz-push-gateway "${production_args[@]}" >/dev/null helm template push deploy/charts/buzz-push-gateway "${production_args[@]}" >"$production_out" +# Gateway API remains an explicit supported ingress mode when an operator opts +# in and supplies the environment-owned parent. +helm template push deploy/charts/buzz-push-gateway \ + --set httpRoute.enabled=true \ + --set 'httpRoute.parentRefs[0].name=production-gateway' \ + --set 'httpRoute.parentRefs[0].namespace=gateway-system' \ + >"$route_out" + env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml -rset \ - - "$out" "$production_out" <<'RUBY' + - "$out" "$production_out" "$route_out" <<'RUBY' def assert!(condition, detail = "assertion failed") raise detail unless condition end @@ -38,6 +45,7 @@ migration = runtime.merge("app.kubernetes.io/component" => "migration") assert!(svc.dig("spec", "selector") == runtime) assert!(d.dig("spec", "selector", "matchLabels") == runtime) assert!(d.dig("spec", "template", "metadata", "labels") == runtime) +assert!(d.dig("spec", "template", "metadata", "annotations").nil?) assert!(j.dig("spec", "template", "metadata", "labels") == migration) assert!(svc.dig("spec", "selector") != j.dig("spec", "template", "metadata", "labels")) jenv = j.dig("spec", "template", "spec", "containers", 0, "env").to_h { |entry| [entry["name"], entry] } @@ -86,7 +94,11 @@ ingress_ports = np.dig("spec", "ingress") .flat_map { |rule| rule.fetch("ports", []) }.map { |port| port["port"] }.to_set assert!(ingress_ports == Set[8080], ingress_ports.inspect) production = YAML.load_stream(File.read(ARGV[1])).compact -route = production.find { |x| x["kind"] == "HTTPRoute" } +assert!(!production.any? { |x| x["kind"] == "HTTPRoute" }) +production_deployment = production.find { |x| x["kind"] == "Deployment" } +production_image = production_deployment.dig("spec", "template", "spec", "containers", 0, "image") +assert!(production_image == "ghcr.io/block/buzz-push-gateway@sha256:#{"a" * 64}", production_image.inspect) +route = YAML.load_stream(File.read(ARGV[2])).compact.find { |x| x["kind"] == "HTTPRoute" } assert!(!route.dig("spec", "parentRefs").empty?) assert!(route.dig("spec", "hostnames").include?("push.buzz.xyz")) RUBY @@ -107,7 +119,7 @@ if helm template push deploy/charts/buzz-push-gateway --set httpRoute.enabled=tr fi # The checked-in production contract is intentionally undeployable until CI or -# the release system supplies an immutable digest and environment-owned values. +# the release system supplies its environment-owned values. if helm template push deploy/charts/buzz-push-gateway -f deploy/charts/buzz-push-gateway/values-production.yaml >/dev/null 2>&1; then echo 'expected uninjected production values to fail' >&2 exit 1 @@ -115,7 +127,7 @@ fi # Enabling observability renders the scrape CRDs and adds a scoped 8081 ingress # keyed to the named monitoring source — never a blanket 8081 rule. -monitoring_out=$(mktemp); trap 'rm -f "$out" "$production_out" "$monitoring_out"' EXIT +monitoring_out=$(mktemp) helm template push deploy/charts/buzz-push-gateway \ --set podMonitor.enabled=true \ --set prometheusRule.enabled=true \ @@ -147,6 +159,44 @@ from = monitoring[0].fetch("from")[0] assert!(!from.dig("namespaceSelector", "matchLabels").empty? && !from.dig("podSelector", "matchLabels").empty?, from.inspect) RUBY +# Datadog discovers the same private endpoint from pod annotations and needs no +# prometheus-operator CRDs. Its agent ingress remains selector-scoped. +helm lint deploy/charts/buzz-push-gateway \ + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml >/dev/null +helm template push deploy/charts/buzz-push-gateway \ + -f deploy/charts/buzz-push-gateway/tests/datadog-values.yaml \ + >"$datadog_out" + +env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -rjson -ryaml -rset \ + - "$datadog_out" <<'RUBY' +def assert!(condition, detail = "assertion failed") + raise detail unless condition +end + +xs = YAML.load_stream(File.read(ARGV[0])).compact +assert!(!xs.any? { |x| %w[PodMonitor PrometheusRule].include?(x["kind"]) }) +deployment = xs.find { |x| x["kind"] == "Deployment" } +raw_check = deployment.dig( + "spec", "template", "metadata", "annotations", + "ad.datadoghq.com/gateway.checks", +) +check = JSON.parse(raw_check) +instance = check.dig("openmetrics", "instances", 0) +assert!(instance["openmetrics_endpoint"] == "http://%%host%%:8081/metrics", instance.inspect) +assert!(instance["metrics"] == ["push_gateway_.*"], instance.inspect) + +np = xs.find do |x| + x["kind"] == "NetworkPolicy" && x.dig("metadata", "name") == "push-buzz-push-gateway" +end +monitoring = np.dig("spec", "ingress").select do |rule| + rule.fetch("ports", []).map { |port| port["port"] }.to_set == Set[8081] +end +assert!(monitoring.length == 1, "exactly one scoped Datadog 8081 ingress rule") +from = monitoring[0].fetch("from")[0] +assert!(!from.dig("namespaceSelector", "matchLabels").empty?, from.inspect) +assert!(!from.dig("podSelector", "matchLabels").empty?, from.inspect) +RUBY + # Negative: monitoring enabled with default empty selectors must fail (would # otherwise render a blanket 8081 rule matching all namespaces/pods). if helm template push deploy/charts/buzz-push-gateway \ @@ -156,9 +206,8 @@ if helm template push deploy/charts/buzz-push-gateway \ exit 1 fi -# Negative: scrape flags must be coupled. PodMonitor without ingress = an -# unreachable scraper; ingress without a PodMonitor = an open hole with no -# scraper. Both mismatches must fail schema validation. +# Negative: PodMonitor without ingress is an unreachable scraper and must fail. +# Scoped ingress without PodMonitor is valid for annotation-discovered agents. if helm template push deploy/charts/buzz-push-gateway \ --set podMonitor.enabled=true \ --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ @@ -167,14 +216,6 @@ if helm template push deploy/charts/buzz-push-gateway \ echo 'expected podMonitor.enabled without monitoring ingress to fail' >&2 exit 1 fi -if helm template push deploy/charts/buzz-push-gateway \ - --set networkPolicy.monitoring.enabled=true \ - --set 'networkPolicy.monitoring.namespaceSelector.kubernetes\.io/metadata\.name=monitoring' \ - --set 'networkPolicy.monitoring.podSelector.app\.kubernetes\.io/name=prometheus' \ - >/dev/null 2>&1; then - echo 'expected monitoring ingress without podMonitor.enabled to fail' >&2 - exit 1 -fi # Negative: retry-ratio threshold is a fraction; a value > 1 must fail schema. if helm template push deploy/charts/buzz-push-gateway \ diff --git a/deploy/charts/buzz-push-gateway/values-production.yaml b/deploy/charts/buzz-push-gateway/values-production.yaml index 8017f6bacdb..85dd8af1a8c 100644 --- a/deploy/charts/buzz-push-gateway/values-production.yaml +++ b/deploy/charts/buzz-push-gateway/values-production.yaml @@ -7,7 +7,9 @@ profiles: dogfood: appAttestAppId: "" httpRoute: - enabled: true + # Keep disabled when the platform already routes push.buzz.xyz to this + # Service. Gateway API users enable it and inject an explicit parentRef. + enabled: false parentRefs: [] hostnames: - push.buzz.xyz diff --git a/deploy/charts/buzz-push-gateway/values.schema.json b/deploy/charts/buzz-push-gateway/values.schema.json index 29eafa22c8d..1339b777e50 100644 --- a/deploy/charts/buzz-push-gateway/values.schema.json +++ b/deploy/charts/buzz-push-gateway/values.schema.json @@ -32,6 +32,12 @@ } }, "apnsKey": false, + "podAnnotations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "httpRoute": { "type": "object", "required": [ @@ -313,7 +319,7 @@ ], "allOf": [ { - "$comment": "Scraping opt-in is coupled: a PodMonitor and its scoped 8081 ingress must be enabled together, so we never render a scraper that cannot reach the port nor an ingress hole with no scraper.", + "$comment": "A PodMonitor requires scoped 8081 ingress. External scrapers such as Datadog may enable that ingress without rendering a PodMonitor.", "if": { "properties": { "podMonitor": { @@ -355,49 +361,6 @@ "networkPolicy" ] } - }, - { - "if": { - "properties": { - "networkPolicy": { - "properties": { - "monitoring": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - } - }, - "required": [ - "monitoring" - ] - } - }, - "required": [ - "networkPolicy" - ] - }, - "then": { - "properties": { - "podMonitor": { - "properties": { - "enabled": { - "const": true - } - }, - "required": [ - "enabled" - ] - } - }, - "required": [ - "podMonitor" - ] - } } ] } diff --git a/deploy/charts/buzz-push-gateway/values.yaml b/deploy/charts/buzz-push-gateway/values.yaml index 1f1e90cbb08..245d1a682ec 100644 --- a/deploy/charts/buzz-push-gateway/values.yaml +++ b/deploy/charts/buzz-push-gateway/values.yaml @@ -34,9 +34,11 @@ appAttestRoot: secretKey: app-attest-root.pem service: port: 8080 +podAnnotations: {} httpRoute: # Disabled by default so a generic install cannot claim an unattached route. - # Production enables this with an explicit Gateway parentRef. + # Enable only when this chart owns a Gateway API route. Environments with an + # existing ingress or service mesh route should keep this disabled. enabled: false parentRefs: [] hostnames: [push.buzz.xyz] @@ -60,8 +62,9 @@ networkPolicy: podSelector: k8s-app: kube-dns # Scoped ingress to the private metrics port (8081). Off by default so 8081 - # has no pod ingress at all; enable only alongside podMonitor and name the - # scraper's namespace/pod so reachability stays narrow. + # has no pod ingress at all; enable alongside podMonitor or an external + # annotation-discovered scraper and name its namespace/pod so reachability + # stays narrow. monitoring: enabled: false namespaceSelector: {} diff --git a/deploy/charts/buzz/README.md b/deploy/charts/buzz/README.md index 8a4b0c6d665..b022dd8a27f 100644 --- a/deploy/charts/buzz/README.md +++ b/deploy/charts/buzz/README.md @@ -115,6 +115,27 @@ disables that probe through `relay.extraEnv`, `/_readiness` does not test object storage; configuration is still parsed strictly, but reachability and addressing errors surface on the first storage operation. +### Readiness telemetry contract + +Only requests served by the private health listener (`BUZZ_HEALTH_PORT`) emit +rollout readiness telemetry. The compatibility `/_readiness` route on the public +app listener returns health but does not change these metrics. + +| Metric | Type | Labels | +|--------|------|--------| +| `buzz_readiness_checks_total` | counter | `reason` from the closed readiness-reason set | +| `buzz_readiness_dependency_checks_total` | counter | `dependency`, typed bounded `outcome` | +| `buzz_readiness_check_duration_seconds` | histogram | `check` only | +| `buzz_readiness_state` | gauge | `check` only; latest publishable generation | + +The schema has a ceiling of 99 raw Prometheus series per pod: 12 overall +reasons, 11 valid dependency/outcome pairs, 72 histogram series, and 4 gauges. +Do not add pod, ReplicaSet, version, rollout, error text, SQL, URL, tenant, +user, community, pubkey, header, query, or other request-controlled labels. +Shutdown without dependency evaluation increments only +`buzz_readiness_checks_total{reason="shutting_down"}` and sets the overall +state to zero; it does not fabricate dependency failures or latency samples. + ## Relay Pod extensions The chart exposes narrow extension points for init containers, volumes, relay diff --git a/desktop/public/harness-logos/CREDITS.md b/desktop/public/harness-logos/CREDITS.md index 716c43e1ae3..dee5aa257e9 100644 --- a/desktop/public/harness-logos/CREDITS.md +++ b/desktop/public/harness-logos/CREDITS.md @@ -13,6 +13,7 @@ license permits redistribution. | `hermes.png` | [NousResearch/hermes-agent](https://github.com/NousResearch/hermes-agent) | `6ad632b` | MIT © 2025 Nous Research | `website/static/img/logo.png` | Cropped the baked-in border frame, padded to square, resized to 64×64, quantised to a 16-colour palette | | `openclaw.svg` | [openclaw/openclaw](https://github.com/openclaw/openclaw) | `b06f40a` | MIT © 2026 OpenClaw Foundation | `ui/public/favicon.svg` | Removed the SMIL animation elements (renders the upstream rest pose statically — verified pixel-identical to the upstream frame at t=0); minified paths | | `omp.svg` | [can1357/oh-my-pi](https://github.com/can1357/oh-my-pi) | `667111575ebba136dadfd6989379e7f67e0d40d9` | MIT © 2025 Mario Zechner; © 2025–2026 Can Bölük | `assets/icon.svg` | None | +| `pi.svg` | [earendil-works/pi-website](https://github.com/earendil-works/pi-website) | `2f5e410b97474d0a34ec2500aa1aa58d6c3f992c` | MIT © 2026 Earendil Inc. and contributors | `src/favicon.svg` | None | | `kimi.png` | [MoonshotAI/kimi-cli](https://github.com/MoonshotAI/kimi-cli) | `4a550effdfcb29a25a5d325bf935296cc50cd417` | Apache-2.0; NOTICE: Kimi Code CLI © 2025 Moonshot AI | `web/public/logo.png` | None | | `grok.svg` | [SpaceXAI brand guidelines](https://x.ai/legal/brand-guidelines) | Retrieved 2026-07-25 | xAI Brand Guidelines: marks may be used to accurately refer to xAI or its services; logos must be used exactly as provided | `SpaceXAI_Grok_Assets.zip` → `Grok_Logomark_Dark.svg` | None | diff --git a/desktop/public/harness-logos/pi.svg b/desktop/public/harness-logos/pi.svg new file mode 100644 index 00000000000..c28d6242332 --- /dev/null +++ b/desktop/public/harness-logos/pi.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index bfe4fcc8570..bc2d179695b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -1,61 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { root: "src-tauri/src", extensions: new Set([".rs"]), maxLines: MAX_LINES }, - // Workspace member crates. Without this the ratchet's only Rust root is - // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the - // repo's one size discipline -- silently, since the check still exits 0. - { - root: "src-tauri/crates", - extensions: new Set([".rs"]), - maxLines: MAX_LINES, - }, - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/context", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/lib", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/ui", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/styles", - extensions: new Set([".css"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Desktop", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/desktop/scripts/file-size-policy.mjs b/desktop/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..5728b9187a6 --- /dev/null +++ b/desktop/scripts/file-size-policy.mjs @@ -0,0 +1,53 @@ +const DESKTOP_FRONTEND_MAX_LINES = 1200; +const DESKTOP_RUST_MAX_LINES = 1500; + +export const rules = [ + { + root: "src-tauri/src", + extensions: new Set([".rs"]), + maxLines: DESKTOP_RUST_MAX_LINES, + }, + // Workspace member crates. Without this the ratchet's only Rust root is + // `src-tauri/src`, and a crate under `src-tauri/crates/` is born outside the + // repo's one size discipline -- silently, since the check still exits 0. + { + root: "src-tauri/crates", + extensions: new Set([".rs"]), + maxLines: DESKTOP_RUST_MAX_LINES, + }, + { + root: "src/app", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/features", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/api", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/context", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/lib", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/ui", + extensions: new Set([".ts", ".tsx"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, + { + root: "src/shared/styles", + extensions: new Set([".css"]), + maxLines: DESKTOP_FRONTEND_MAX_LINES, + }, +]; diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 6f6ec1a4f18..b2d0f66cc85 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1041,6 +1041,7 @@ dependencies = [ "axum", "base64 0.22.1", "dirs", + "fs2", "getrandom 0.4.3", "hex", "nix 0.31.3", @@ -3084,6 +3085,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fs_extra" version = "1.3.0" diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index cc19bd72da2..ee135f136fe 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -634,23 +634,20 @@ fn resolve_identity_with_store( }) } -/// Recover from a corrupt nsec in the keyring (parse failed). Clear the bad -/// keyring value, then migrate a valid leftover `identity.key` if one exists. -/// If the migration marker is present but no valid file exists, the prior -/// identity is unrecoverable — return `Lost` recovery rather than silently -/// generating a new identity. Generating fresh is only correct when no prior -/// identity ever existed (no marker). The keyring delete is best-effort: a -/// delete failure logs and continues — it must never block startup. +/// Recover from an unparseable keyring nsec, preferring a valid `identity.key`. +/// If a migration marker exists without a valid file, retain the keyring value +/// and return `Lost`. Without a marker, preserve the existing generate-fresh policy. fn recover_from_keyring( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, error: &str, ) -> Result { - eprintln!("buzz-desktop: corrupt nsec in keyring ({error}), clearing and recovering from file"); - if let Err(e) = store.delete(IDENTITY_KEY_NAME) { - eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}"); - } + eprintln!( + "buzz-desktop: corrupt nsec in keyring ({error}), looking for a recovery path before clearing" + ); + // Marker-only installs have no file fallback. Keep unreadable keyring + // material until a replacement exists rather than destroying the only copy. if legacy_path.exists() { if let Some(keys) = migrate_identity_file(store, legacy_path, data_dir)? { return Ok(ResolvedIdentity { @@ -661,13 +658,13 @@ fn recover_from_keyring( } } // No valid file to recover from. If the migration marker exists, a prior - // identity was stored in the keyring and is now corrupt AND gone — the key - // is unrecoverable. Enter Lost recovery instead of silently rotating. + // identity was stored in the keyring — keep the corrupt entry for support / + // manual export and enter Lost rather than silently rotating. if migration_marker_path(data_dir).exists() { let ephemeral = Keys::generate(); eprintln!( - "buzz-desktop: identity lost — keyring had corrupt data and no valid identity.key \ - backup; prior identity (migration marker present) is unrecoverable; \ + "buzz-desktop: identity lost — keyring value failed to parse and no valid identity.key \ + backup exists; leaving the keyring entry in place; \ using ephemeral key {}, awaiting user re-import", ephemeral.public_key().to_hex() ); @@ -677,7 +674,10 @@ fn recover_from_keyring( storage: IdentityStorage::Ephemeral, }); } - // No marker: genuine first launch with a corrupt keyring. Generate fresh. + // No marker: preserve the existing clear-and-generate first-launch policy. + if let Err(e) = store.delete(IDENTITY_KEY_NAME) { + eprintln!("buzz-desktop: failed to clear corrupt keyring value: {e}"); + } let (keys, storage) = generate_and_persist(store, legacy_path, data_dir)?; Ok(ResolvedIdentity { keys, diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 751bcf22e59..ceef4d3f93e 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -326,8 +326,8 @@ fn corrupt_keyring_recovers_valid_file_without_rotating() { // nsec (Present) AND a valid `identity.key` is on disk (leftover from a // failed prior migration), recovery must RECOVER THE FILE'S identity — // not quarantine the file and rotate to a fresh key (the original - // hazard). The corrupt keyring value must be cleared and replaced by the - // file's key (migrated in). + // hazard). Recovery must overwrite the corrupt keyring value with the + // file's key without deleting the keyring entry first. let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); let file_keys = Keys::generate(); @@ -338,8 +338,8 @@ fn corrupt_keyring_recovers_valid_file_without_rotating() { // The FILE's identity is recovered — NOT a freshly generated one. assert_key_eq(&file_keys, &resolved.keys); - // The corrupt keyring value was cleared. - assert_eq!(store.deleted.borrow().as_slice(), [IDENTITY_KEY_NAME]); + // Recovery overwrites the corrupt value without deleting first. + assert!(store.deleted.borrow().is_empty()); // The keyring now holds the file's key (migrated in, read-back verified). let file_nsec = file_keys.secret_key().to_bech32().unwrap(); assert_eq!( @@ -1363,13 +1363,9 @@ fn verify_fails_store_does_not_write_marker_or_delete_file() { ); } -// ── I2: corrupt keyring + marker = Lost recovery ────────────────────────── - #[test] fn corrupt_keyring_marker_present_no_file_is_lost() { - // I2: Present(corrupt) + migration marker + no identity.key → the prior - // identity was migrated into the keyring and is now unrecoverable (corrupt - // AND no file backup). Must enter Lost recovery, NOT generate a fresh key. + // I2: corrupt keyring + marker + no file → Lost (do not mint a fresh key). let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); write_migration_marker(&migration_marker_path(dir.path())).unwrap(); @@ -1378,22 +1374,33 @@ fn corrupt_keyring_marker_present_no_file_is_lost() { let store = FakeIdentityStore::present_with("not-a-valid-nsec"); let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - // Must enter Lost recovery — a prior identity existed and is now unrecoverable. - assert_eq!( - resolved.recovery, - RecoveryState::Lost, - "corrupt keyring + marker + no file must return Lost recovery, not a fresh key" - ); - - // No identity.key written — the ephemeral key is in-memory only. + assert_eq!(resolved.recovery, RecoveryState::Lost); + // Lost must keep the corrupt keyring entry for support/export. + assert!(!store + .deleted + .borrow() + .contains(&IDENTITY_KEY_NAME.to_string())); + assert!(store.slot.borrow().contains_key(IDENTITY_KEY_NAME)); assert!(!legacy_path.exists()); } +#[test] +fn corrupt_keyring_with_valid_file_recovers_before_delete() { + let dir = tempfile::tempdir().unwrap(); + let legacy_path = dir.path().join("identity.key"); + let file_keys = Keys::generate(); + save_key_file(&legacy_path, &file_keys).unwrap(); + write_migration_marker(&migration_marker_path(dir.path())).unwrap(); + let store = FakeIdentityStore::present_with("not-a-valid-nsec"); + let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); + assert_eq!(resolved.recovery, RecoveryState::None); + assert_key_eq(&file_keys, &resolved.keys); + assert!(store.deleted.borrow().is_empty()); +} + #[test] fn corrupt_keyring_no_marker_no_file_generates_fresh() { - // I2 (counter-case): Present(corrupt) + NO marker + no identity.key → - // genuine first launch with a corrupt keyring, no prior identity to - // protect. generate_and_persist is still the correct last resort. + // I2 counter-case: corrupt keyring, no marker, no file → generate fresh. let dir = tempfile::tempdir().unwrap(); let legacy_path = dir.path().join("identity.key"); assert!(!legacy_path.exists()); @@ -1402,16 +1409,9 @@ fn corrupt_keyring_no_marker_no_file_generates_fresh() { let store = FakeIdentityStore::present_with("not-a-valid-nsec"); let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); - // No lost recovery — this is a fresh machine with no prior identity. - assert_eq!( - resolved.recovery, - RecoveryState::None, - "corrupt keyring + no marker + no file must generate a fresh key (no prior identity)" - ); - - // A fresh, valid key was stored (keyring or file). + assert_eq!(resolved.recovery, RecoveryState::None); assert!( store.slot.borrow().contains_key(IDENTITY_KEY_NAME) || legacy_path.exists(), - "a fresh key must be stored in the keyring or the file after generate_and_persist" + "fresh key must be stored after generate_and_persist" ); } diff --git a/desktop/src-tauri/src/archive/metric_store.rs b/desktop/src-tauri/src/archive/metric_store.rs index 9595e4d3323..78363223063 100644 --- a/desktop/src-tauri/src/archive/metric_store.rs +++ b/desktop/src-tauri/src/archive/metric_store.rs @@ -9,7 +9,7 @@ //! via [`AgentMetricIndexRow::from_payload`]. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `pipeline.rs` precedent. +//! 1500-line gate, per the existing `pipeline.rs` precedent. use rusqlite::{params, Connection, OptionalExtension}; diff --git a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs index 2dc568d701c..337dbac922b 100644 --- a/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs +++ b/desktop/src-tauri/src/archive/mod_agent_metric_tests.rs @@ -1,7 +1,7 @@ //! Kind-44200 (NIP-AM agent turn metric) archive and `get_agent_usage_series` //! integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from there so the shared fixtures (`in_memory`, //! `add_sub`, `candidate`, `make_observer_frame`, `run_batch_sync_with_keys`) //! stay private to `mod_tests`. diff --git a/desktop/src-tauri/src/archive/mod_tests.rs b/desktop/src-tauri/src/archive/mod_tests.rs index 21587669268..c589b5bd522 100644 --- a/desktop/src-tauri/src/archive/mod_tests.rs +++ b/desktop/src-tauri/src/archive/mod_tests.rs @@ -1,6 +1,6 @@ //! Unit and integration tests for `archive/mod.rs`. //! -//! Kept in a sibling file so `mod.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `mod.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::pipeline::BucketWithResult; @@ -622,7 +622,7 @@ fn test_commit_archive_rolls_back_when_scope_write_would_fail() { } // Kind-44200 agent-turn-metric coverage lives in a sibling file to keep this -// one under the 1000-line gate; nested here (not in `mod.rs`) so it inherits +// one under the 1500-line gate; nested here (not in `mod.rs`) so it inherits // the shared fixtures above through `use super::*`. #[path = "mod_agent_metric_tests.rs"] mod agent_metric; diff --git a/desktop/src-tauri/src/archive/pipeline.rs b/desktop/src-tauri/src/archive/pipeline.rs index 98ff64dff48..f2fb3e6b895 100644 --- a/desktop/src-tauri/src/archive/pipeline.rs +++ b/desktop/src-tauri/src/archive/pipeline.rs @@ -1,6 +1,6 @@ //! Archive pipeline — three-phase plan/query/commit split. //! -//! Separated from `mod.rs` to keep that file under the 1000-line gate. +//! Separated from `mod.rs` to keep that file under the 1500-line gate. //! //! # Send-safety //! diff --git a/desktop/src-tauri/src/archive/retention.rs b/desktop/src-tauri/src/archive/retention.rs index 5ee9acff200..2e150da97a7 100644 --- a/desktop/src-tauri/src/archive/retention.rs +++ b/desktop/src-tauri/src/archive/retention.rs @@ -10,7 +10,7 @@ //! Phase-2 prune scan, the get/set accessors for the observer window, and the //! PRAGMA-based size readout. The prune worker itself lands in Phase 2. //! -//! Kept in a sibling file (not `store.rs`) to respect the 1000-line gate, per +//! Kept in a sibling file (not `store.rs`) to respect the 1500-line gate, per //! the existing `metric_store.rs` / `pipeline.rs` / `store_migrations.rs` //! precedent. diff --git a/desktop/src-tauri/src/archive/retention_tests.rs b/desktop/src-tauri/src/archive/retention_tests.rs index 26e6a25fdae..122cd01a99a 100644 --- a/desktop/src-tauri/src/archive/retention_tests.rs +++ b/desktop/src-tauri/src/archive/retention_tests.rs @@ -1,7 +1,7 @@ //! Behavior tests for the observer-retention setting, the size readout, and the //! M4 migration. //! -//! Kept in a sibling file so `retention.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `retention.rs` stays under the 1500-line gate; //! `#[path]`-included from there. `super::*` brings the retention API (and its //! `rusqlite::{params, Connection}` imports) into scope; `super::super::store` //! reaches the neighbouring subscription mutators and the base `SCHEMA`. diff --git a/desktop/src-tauri/src/archive/store_migration_tests.rs b/desktop/src-tauri/src/archive/store_migration_tests.rs index 6a40d7f4cd7..6aa585cfb46 100644 --- a/desktop/src-tauri/src/archive/store_migration_tests.rs +++ b/desktop/src-tauri/src/archive/store_migration_tests.rs @@ -1,6 +1,6 @@ //! Migration tests for `archive/store.rs` — M1: harness column. //! -//! Kept in a sibling file so `store_tests.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store_tests.rs` stays under the 1500-line gate; //! `#[path]`-included from `store.rs`. use super::*; diff --git a/desktop/src-tauri/src/archive/store_migrations.rs b/desktop/src-tauri/src/archive/store_migrations.rs index 35a21e25d45..82a24e581c3 100644 --- a/desktop/src-tauri/src/archive/store_migrations.rs +++ b/desktop/src-tauri/src/archive/store_migrations.rs @@ -4,7 +4,7 @@ //! `archive_migrations`, so a migration that already ran is a no-op. //! //! Kept in a sibling file (not `store.rs`) to keep that file under the -//! 1000-line gate, per the existing `metric_store.rs` / `pipeline.rs` +//! 1500-line gate, per the existing `metric_store.rs` / `pipeline.rs` //! precedent. use rusqlite::{params, Connection}; diff --git a/desktop/src-tauri/src/archive/store_tests.rs b/desktop/src-tauri/src/archive/store_tests.rs index c0f85430d4d..b7e02d8f4dc 100644 --- a/desktop/src-tauri/src/archive/store_tests.rs +++ b/desktop/src-tauri/src/archive/store_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `archive/store.rs`. //! -//! Kept in a sibling file so `store.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `store.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::*; diff --git a/desktop/src-tauri/src/commands/agent_config_tests.rs b/desktop/src-tauri/src/commands/agent_config_tests.rs index fa268acee61..45692e9e5fb 100644 --- a/desktop/src-tauri/src/commands/agent_config_tests.rs +++ b/desktop/src-tauri/src/commands/agent_config_tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `commands/agent_config.rs` (split to keep `agent_config.rs` -//! under the 1000-line file-size ratchet). +//! under the 1500-line file-size ratchet). //! //! Included via `#[path = "agent_config_tests.rs"] mod tests;` at the bottom of //! `agent_config.rs`, so `use super::*` gives access to all items in that module. diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs index 407ab449744..d05e0f2480b 100644 --- a/desktop/src-tauri/src/commands/personas/card/tests.rs +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `card.rs` — split into a child module file so the parent -//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`). +//! stays under the 1500-line gate (same layout as `snapshot/tests.rs`). use super::*; use std::collections::BTreeMap; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index c996c7ee2ea..17eb1825c9f 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -2,7 +2,7 @@ //! and their supporting helpers. //! //! Import-side commands and helpers live in `snapshot::import` to keep this -//! file under the 1000-line gate. +//! file under the 1500-line gate. //! //! Split from `personas/mod.rs` to keep that file under the line-count gate. diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index db1cfde1ce9..2eb06e2c5d9 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -1,6 +1,6 @@ //! Import-side helpers for `buzz-agent-snapshot v1`. //! -//! Extracted from `snapshot.rs` to keep that file under the 1000-line gate. +//! Extracted from `snapshot.rs` to keep that file under the 1500-line gate. //! The Tauri commands here (`preview_agent_snapshot_import`, //! `confirm_agent_snapshot_import`) are re-exported from `snapshot.rs` and //! registered in `lib.rs` through the same `personas::` path as the export diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs index 36eaa997163..136ef65a453 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -1,7 +1,7 @@ //! Export-size guard tests for `validate_snapshot_encode_size`. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the -//! 1000-line gate; `#[path]`-included from there as a child module, +//! 1500-line gate; `#[path]`-included from there as a child module, //! so `super::*` still resolves to the shared test imports. //! //! Tests call `validate_snapshot_encode_size` directly so they prove the diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs index 296444f78d0..43ca23cc822 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -1,7 +1,7 @@ //! Locked-card import tests for `decode_snapshot_for_import`. //! //! Kept in a sibling file so `snapshot/tests.rs` stays under the -//! 1000-line gate; `#[path]`-included from there as a child module, +//! 1500-line gate; `#[path]`-included from there as a child module, //! so `super::*` still resolves to the shared test helpers. use super::*; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs index b17efa1ad11..e327cb0e491 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -1,6 +1,6 @@ //! Tests for `memory_entries_from_listing` — the shared level → entries //! selection used by both snapshot export and card minting. Split from -//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included +//! `tests.rs` to keep that file under the 1500-line gate; `#[path]`-included //! from there as a child module, so `super::*` resolves to `tests`'s parent //! scope re-exports. diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs index 202ad318750..e57e9289271 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -1,7 +1,7 @@ //! Unit tests for `managed_agents/agent_snapshot.rs`. //! //! Kept in a sibling file so `agent_snapshot.rs` stays under the -//! 1000-line gate; `#[path]`-included from there. +//! 1500-line gate; `#[path]`-included from there. use super::*; use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index aa3fe1b35c6..aaee3558aac 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -1,5 +1,5 @@ //! Unit tests for `config_bridge/reader.rs` (kept in a sibling file so -//! `reader.rs` stays under the 1000-line budget; `#[path]`-included from +//! `reader.rs` stays under the 1500-line budget; `#[path]`-included from //! there). use std::{collections::BTreeMap, path::Path, sync::Mutex}; diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs index f86793f91a1..0974bf9c581 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests_ext.rs @@ -1,5 +1,5 @@ //! Additional tests for `config_bridge/reader.rs` — split out to keep -//! `reader_tests.rs` under the 1000-line file-size ratchet. +//! `reader_tests.rs` under the 1500-line file-size ratchet. //! //! Included as `mod ext` inside `reader_tests.rs`, so `use super::*` gives //! access to all helpers and types from that module. diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index 9208a79c6e1..79961785966 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -90,6 +90,15 @@ pub(super) fn preset_catalog_entry( } pub(super) const PRESET_HARNESSES: &[PresetHarness] = &[ + PresetHarness { + id: "pi", + label: "Pi", + command: "pi-acp", + args: &[], + install_instructions_url: "https://github.com/svkozak/pi-acp", + install_hint: "Buzz talks to Pi through the pi-acp adapter. Install Pi with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`, then install the adapter with `npm install -g pi-acp`.", + underlying_cli: Some("pi"), + }, PresetHarness { id: "devin", label: "Devin", @@ -347,6 +356,48 @@ mod tests { assert_eq!(entry.source, HarnessSource::Preset); } + #[test] + fn pi_preset_uses_zero_arg_adapter_and_reports_missing_component() { + let preset = PRESET_HARNESSES + .iter() + .find(|preset| preset.id == "pi") + .expect("Pi preset should be present"); + + assert_eq!(preset.label, "Pi"); + assert_eq!(preset.command, "pi-acp"); + assert!(preset.args.is_empty()); + assert_eq!(preset.underlying_cli, Some("pi")); + + let available = preset_catalog_entry(preset, |command| match command { + "pi-acp" => Some(PathBuf::from("/usr/local/bin/pi-acp")), + "pi" => Some(PathBuf::from("/usr/local/bin/pi")), + _ => None, + }); + assert_eq!(available.availability, AcpAvailabilityStatus::Available); + assert_eq!(available.command.as_deref(), Some("pi-acp")); + assert!(available.default_args.is_empty()); + assert_eq!( + available.underlying_cli_path.as_deref(), + Some("/usr/local/bin/pi") + ); + + let adapter_missing = preset_catalog_entry(preset, |command| { + (command == "pi").then(|| PathBuf::from("/usr/local/bin/pi")) + }); + assert_eq!( + adapter_missing.availability, + AcpAvailabilityStatus::AdapterMissing + ); + assert!(adapter_missing.command.is_none()); + assert!(adapter_missing.default_args.is_empty()); + + let not_installed = preset_catalog_entry(preset, |_| None); + assert_eq!( + not_installed.availability, + AcpAvailabilityStatus::NotInstalled + ); + } + #[test] fn adapter_missing_when_underlying_cli_present() { let entry = preset_catalog_entry(&ADAPTER_PRESET, |command| { diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs index 18b2fac0746..03bba90cff9 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests_ext.rs @@ -1,5 +1,5 @@ //! B5 effort lifecycle tests split out of `spawn_snapshot/tests.rs` to hold -//! that file under the 1000-line file-size ratchet. +//! that file under the 1500-line file-size ratchet. //! //! Included as `mod ext` inside `tests.rs`, so `use super::*` gives access to //! its `record`, `snap`, and `record_with_env_effort` helpers. diff --git a/desktop/src-tauri/src/managed_agents/storage_tests.rs b/desktop/src-tauri/src/managed_agents/storage_tests.rs index 9943c6b3ac3..d39fcf41009 100644 --- a/desktop/src-tauri/src/managed_agents/storage_tests.rs +++ b/desktop/src-tauri/src/managed_agents/storage_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/storage.rs`. //! -//! Kept in a sibling file so `storage.rs` stays closer to the 1000-line gate; +//! Kept in a sibling file so `storage.rs` stays closer to the 1500-line gate; //! `#[path]`-included from there. use std::cell::RefCell; diff --git a/desktop/src-tauri/src/managed_agents/teams_tests.rs b/desktop/src-tauri/src/managed_agents/teams_tests.rs index 8fc199527d3..4908225361e 100644 --- a/desktop/src-tauri/src/managed_agents/teams_tests.rs +++ b/desktop/src-tauri/src/managed_agents/teams_tests.rs @@ -1,6 +1,6 @@ //! Unit tests for `managed_agents/teams.rs`. //! -//! Kept in a sibling file so `teams.rs` stays under the 1000-line gate; +//! Kept in a sibling file so `teams.rs` stays under the 1500-line gate; //! `#[path]`-included from there. use super::{ diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index c332fb72999..64509387feb 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -153,8 +153,8 @@ with a TypeScript lookup table or an id comparison in a component. place that resolves it for dialog surfaces and publishes it through `ui/AgentRunLocationContext.tsx`; the field reads that context and lets an explicit `runLocation` prop win. Do **not** thread the value as a prop - through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — both are - already over the 1000-line ceiling, and neither uses the value itself. + through `AgentDefinitionDialog` / `AgentInstanceEditDialog` — neither uses + the value itself, and the shared context keeps the dialog boundary stable. Surfaces rendered outside `AgentDialog` (e.g. `EditRespondToDialog`) pass the prop directly. Local names "your computer, including files, accounts, and connected tools"; remote names "the @@ -216,9 +216,9 @@ with a TypeScript lookup table or an id comparison in a component. cosmetic — the Rust command rejects non-local backends because remote effort is set at deploy time via `policy_env`. Because it reads its inputs from the config surface the dialog already fetches (`useAgentConfigSurface`) and owns - its own mutation, it does **not** thread new props through the over-1000-line - dialog (see rule 11): keep effort state inside the section component, never - as dialog-level props. The read-only display is the `thinkingEffort` + its own mutation, it does **not** thread new props through the dialog (see + rule 11): keep effort state inside the section component, never as + dialog-level props. The read-only display is the `thinkingEffort` normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which already shows both facts — `field.value` (canonical, the effort the next spawn will launch with) and, when a running ACP session differs, diff --git a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs index 492c1cd6acf..e0d150cc4f4 100644 --- a/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs +++ b/desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs @@ -25,10 +25,10 @@ const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8")); // (`_group`) are skipped. Mirrors the Rust corpus filter. const executable = corpus.filter((entry) => entry.expect != null); -test("corpus has exactly 139 executable vectors", () => { +test("corpus has exactly 140 executable vectors", () => { // Locks the vector count so a silent corpus edit can't quietly drop coverage; // must equal the gate in the Rust suite (model_capabilities.rs). - assert.equal(executable.length, 139); + assert.equal(executable.length, 140); }); test("registry label aliases refuse an unprefixed query", () => { @@ -45,7 +45,7 @@ test("registry label aliases refuse an unprefixed query", () => { ); }); -test("UC model-family FQNs and goose- aliases humanize onto their base records", () => { +test("UC model-family FQNs humanize onto their base records", () => { // #6918 follow-up: the shared UC-FQN (`system.ai.…`) and goose- alias forms // must resolve onto the same base databricks_v2 records via the new family // tokens. Mirrors the Rust `test_databricks_registry_label_lookup` coverage. @@ -64,13 +64,10 @@ test("UC model-family FQNs and goose- aliases humanize onto their base records", ["system.ai.qwen35-122b-a10b", "Qwen3.5 122B A10B"], ["system.ai.gemma-3-12b", "Gemma 3 12B"], ["system.ai.inkling", "Inkling"], - [ - "data_workflow_tools.goose.goose-deepseek-v4-flash-0731", - "DeepSeek V4 Flash", - ], - ["data_workflow_tools.goose.goose-glm-5-3", "GLM-5.3"], - ["data_workflow_tools.goose.goose-glm-5-3-flash", "GLM-5.3 Flash"], - ["data_workflow_tools.goose.goose-grok-4-6", "Grok 4.6"], + ["system.ai.deepseek-v4-flash-0731", "DeepSeek V4 Flash"], + ["system.ai.glm-5-3", "GLM-5.3"], + ["system.ai.glm-5-3-flash", "GLM-5.3 Flash"], + ["system.ai.grok-4-6", "Grok 4.6"], ]; for (const [fqn, label] of cases) { assert.equal(databricksRegistryLabel(fqn), label, `fqn=${fqn}`); @@ -97,10 +94,7 @@ test("registry label aliases refuse ambiguous stripped record keys", () => { }); test("Unity Catalog FQNs use neutral concrete-unknown capabilities", () => { - const fqn = resolveModelCapabilities( - "databricks_v2", - "data_workflow_tools.goose.goose-kimi-k3", - ); + const fqn = resolveModelCapabilities("databricks_v2", "system.ai.kimi-k3"); const fallback = resolveModelCapabilities( "databricks_v2", "some-unknown-xyz", diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 8b56ec8af99..ea3c2647865 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -754,7 +754,6 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : null} message.id === item.conversationId) + ?.tags ?? []) + : []; const contextLabel = isThreadContext ? isDirectMessage ? `Thread with ${item.senderLabel}` @@ -804,7 +808,14 @@ function InboxMessageDetailPane({ />
{ assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); }); +test("initialization preserves exclusions across thread remounts", async () => { + const store = await loadStore(14); + const scope = `${ownerA}:channel-a:thread-a`; + + store.initializePersistentAgentAudience(scope, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); + + store.excludePersistentAgentAudienceMember(scope, agentA); + store.initializePersistentAgentAudience(scope, [agentA]); + assert.deepEqual(currentAudiences(store), { [scope]: [] }); + + store.addPersistentAgentAudienceMember(scope, agentA); + assert.deepEqual(currentAudiences(store), { [scope]: [agentA] }); +}); + test("explicit re-selection reinstates an excluded agent", async () => { const store = await loadStore(13); const scope = `${ownerA}:channel-a:channel`; diff --git a/desktop/src/features/messages/lib/persistentAgentAudience.ts b/desktop/src/features/messages/lib/persistentAgentAudience.ts index 78819e0c735..bda3ecabd4d 100644 --- a/desktop/src/features/messages/lib/persistentAgentAudience.ts +++ b/desktop/src/features/messages/lib/persistentAgentAudience.ts @@ -160,6 +160,23 @@ export function removePersistentAgentAudienceMembersIfUnchanged({ return true; } +export function initializePersistentAgentAudience( + scope: string, + pubkeys: Iterable, +): void { + if (!scope) return; + const excluded = excludedPubkeysByScope.get(scope); + const initialPubkeys = normalizePubkeys(pubkeys).filter( + (pubkey) => + !(audiences[scope] ?? []).includes(pubkey) && !excluded?.has(pubkey), + ); + if (initialPubkeys.length === 0) return; + setPersistentAgentAudience(scope, [ + ...(audiences[scope] ?? []), + ...initialPubkeys, + ]); +} + export function addPersistentAgentAudienceMember( scope: string, pubkey: string, diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs index 5dfc851bfd2..91c69a962ed 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs +++ b/desktop/src/features/messages/ui/ComposerAddressControls.test.mjs @@ -72,7 +72,7 @@ test("mention control expands with automatically mentioned agents", async () => const avatar = view.getByTestId("composer-address-lock-agent-pubkey"); assert.ok(avatar); const manage = view.getByRole("button", { - name: "Manage automatic agent mentions", + name: "Manage mentions", }); assert.match(manage.className, /(?:^|\s)-ml-2(?:\s|$)/); assert.match(manage.className, /(?:^|\s)pl-2(?:\s|$)/); @@ -82,18 +82,18 @@ test("mention control expands with automatically mentioned agents", async () => /(?:^|\s)pr-1\.5(?:\s|$)/, ); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)bg-primary\/15(?:\s|$)/, ); assert.match( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)text-primary(?:\s|$)/, ); assert.doesNotMatch( - view.getByRole("button", { name: "Manage automatic agent mentions" }) - .parentElement?.className ?? "", + view.getByRole("button", { name: "Manage mentions" }).parentElement + ?.className ?? "", /(?:^|\s)bg-accent\/70(?:\s|$)/, ); assert.doesNotMatch( @@ -110,7 +110,13 @@ test("mention control expands with automatically mentioned agents", async () => /scale\(0.8\)/, ); } - const remove = view.getByTestId("composer-address-lock-remove-agent-pubkey"); + const remove = view.getByRole("button", { + name: "Don't automatically mention Agent Ada in this thread", + }); + assert.equal( + remove.getAttribute("aria-label")?.includes("conversation"), + false, + ); const removeChrome = remove.querySelector("span.absolute"); assert.match( removeChrome?.className ?? "", diff --git a/desktop/src/features/messages/ui/ComposerAddressControls.tsx b/desktop/src/features/messages/ui/ComposerAddressControls.tsx index 795d07c380f..5ce8e117fb0 100644 --- a/desktop/src/features/messages/ui/ComposerAddressControls.tsx +++ b/desktop/src/features/messages/ui/ComposerAddressControls.tsx @@ -180,11 +180,7 @@ export function ComposerMentionButton({ + + Automatically mention agents + + + Address selected agents in thread replies + + + event.preventDefault()} + /> +
) : null} - {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard, same as the options surface — here it covers presses on the scrollbar and the list's padding ring. */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: pointer-only guard keeps padding and scrollbar presses from blurring the owning editor. */}
{isAlwaysAddressed - ? "Don't automatically mention in this conversation" + ? "Don't automatically mention in this thread" : "Automatically mention"} {alwaysAddressShortcut ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index ecdfb014312..888370e7a39 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -28,14 +28,8 @@ import { import { useComposerFocusOwnership } from "@/features/messages/lib/useComposerFocusOwnership"; import { isMentionCodeContext } from "@/features/messages/lib/mentionCodeContext"; import { useMentions } from "@/features/messages/lib/useMentions"; -import { - getPersistentAgentAudienceScope, - usePersistentAgentAudience, -} from "@/features/messages/lib/persistentAgentAudience"; -import { - setKeepMentionedAgentsPinned, - useKeepMentionedAgentsPinned, -} from "@/features/messages/lib/autoPinMentionedAgentsPreference"; +import { getPersistentAgentAudienceScope } from "@/features/messages/lib/persistentAgentAudience"; +import { setKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; import { useIdentityQuery } from "@/shared/api/hooks"; import { CUSTOM_EMOJI_NODE_NAME } from "@/features/messages/lib/customEmojiNode"; import { @@ -66,6 +60,7 @@ import { useComposerContentState } from "./useComposerContentState"; import { useComposerPasteHandler } from "./useComposerPasteHandler"; import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot"; import { useImplicitAgentMentionProvenance } from "./useImplicitAgentMentionProvenance"; +import { useThreadAgentAudience } from "./useThreadAgentAudience"; import { submitMessageEdit } from "./submitMessageEdit"; import { prepareBackgroundLinkPreviews } from "@/features/messages/lib/linkPreviewPreparationStore"; import { useComposerLinkPreviews } from "./useComposerLinkPreviews"; @@ -326,8 +321,12 @@ function MessageComposerImpl({ onLinkSelectionChangeRef.current = linkEditor.showFromCursor; onLinkShortcutRef.current = linkEditor.openFromShortcut; useComposerSpoilerParticles(richText.editor, composerScrollRef); - const persistentAudience = usePersistentAgentAudience(audienceScope); - const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); + const { audience: persistentAudience, keepMentionedAgentsPinned } = + useThreadAgentAudience({ + isAgentPubkey: mentions.isAgentPubkey, + rootTags: audienceContext?.rootTags ?? [], + scope: audienceScope, + }); const addressPulse = useAddressMentionPulse(); const { completeOptionsReveal: completeMentionOptionsReveal, @@ -425,16 +424,11 @@ function MessageComposerImpl({ setSpoileredAttachmentUrls(restoredSpoileredAttachmentUrls); } }, [editTarget?.id]); - // ── Focus on reply ────────────────────────────────────────────────── - // Use focusPreserve so that re-renders (e.g. new messages arriving in - // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { if (!replyTarget || composerDisabled) return; richText.focusPreserve(); }, [composerDisabled, replyTarget, richText.focusPreserve]); useComposerAutofocus(richText.focus, effectiveDraftKey, composerDisabled); - // Hooks return a plain-text edit descriptor; `replacePlainTextRange` - // applies it as a single ProseMirror transaction (no markdown round-trip). const applyAutocompleteEdit = React.useCallback( (edit: AutocompleteEdit) => { richText.replacePlainTextRange( @@ -511,10 +505,6 @@ function MessageComposerImpl({ const insertEmoji = React.useCallback( (emoji: string) => { if (!richText.editor) return; - // A `:shortcode:` for a known custom emoji becomes a selectable atom - // node (same as the input rule / autocomplete), so it can be selected, - // copied, and deleted as one unit. Everything else (native unicode) - // inserts as plain content. const match = /^:([^:\s]+):$/.exec(emoji); const shortcode = match?.[1]?.toLowerCase(); const known = @@ -891,7 +881,11 @@ function MessageComposerImpl({ onEmojiSelect={applyEmojiInsert} onMentionSelect={selectMentionSuggestion} onOptionsRevealComplete={completeMentionOptionsReveal} - onToggleAlwaysAddressAgent={toggleAlwaysAddressAgent} + onToggleAlwaysAddressAgent={(suggestion) => + toggleAlwaysAddressAgent(suggestion, { + preserveMention: true, + }) + } /> {media.uploadState.status === "error" ? (
diff --git a/desktop/src/features/messages/ui/MessageComposer.types.ts b/desktop/src/features/messages/ui/MessageComposer.types.ts index ac3e40d57d4..5704988e306 100644 --- a/desktop/src/features/messages/ui/MessageComposer.types.ts +++ b/desktop/src/features/messages/ui/MessageComposer.types.ts @@ -25,7 +25,8 @@ export type MessageComposerEditTarget = { export type MessageComposerProps = { audienceContext?: { - type: "channel" | "thread"; + rootTags?: readonly string[][]; + type: "thread"; } | null; channelId?: string | null; channelName: string; diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 16ee26bcc79..1de7b140fdb 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -830,7 +830,10 @@ export function MessageThreadPanel({ > { +test("only thread conversation hosts opt into persistent audiences", async () => { const [channelPane, threadPanel, newMessage, inboxDetail] = await Promise.all( [ source("../../channels/ui/ChannelPane.tsx"), @@ -16,9 +16,12 @@ test("supported conversation hosts opt into explicit audience contexts", async ( ], ); - assert.match(channelPane, /audienceContext=\{\{ type: "channel" \}\}/); + assert.doesNotMatch(channelPane, /audienceContext=/); assert.doesNotMatch(newMessage, /audienceContext=/); - assert.match(threadPanel, /audienceContext=\{\{ type: "thread" \}\}/); + assert.match( + threadPanel, + /audienceContext=\{\{[\s\S]*type: "thread",[\s\S]*rootTags: threadHead\.tags,[\s\S]*\}\}/, + ); assert.match(inboxDetail, /type: "thread"/); assert.doesNotMatch(threadPanel, /audienceContext=\{[\s\S]*threadRootId/); assert.doesNotMatch(inboxDetail, /audienceContext=\{[\s\S]*threadRootId/); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs index 8ed155234fa..60ead286164 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.test.mjs @@ -140,7 +140,7 @@ test("always addressing a new agent delegates the first add for immediate confir assert.deepEqual(pulsedPubkeys, []); }); -test("toggling an addressed agent keeps autocomplete open and removes the lock", async () => { +test("unpinning an addressed agent keeps its current mention and autocomplete open", async () => { const { act, renderHook } = await import("@testing-library/react"); const { useAgentAddressLockPicker } = await import( "./useAgentAddressLockPicker.ts" @@ -187,20 +187,17 @@ test("toggling an addressed agent keeps autocomplete open and removes the lock", ); act(() => { - result.current.toggleAlwaysAddressAgent({ - pubkey: "agent-pubkey", - displayName: "Agent Ada", - isAgent: true, - }); + result.current.toggleAlwaysAddressAgent( + { + pubkey: "agent-pubkey", + displayName: "Agent Ada", + isAgent: true, + }, + { preserveMention: true }, + ); }); - assert.deepEqual(appliedEdits, [ - { - replaceFromOffset: 4, - replaceToOffset: 15, - insertText: "", - }, - ]); + assert.deepEqual(appliedEdits, []); assert.equal(cancelCount, 0); assert.deepEqual(removedPubkeys, ["agent-pubkey"]); assert.deepEqual(pulsedPubkeys, []); diff --git a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts index 4a9ca5f15c1..4dfe40b14b0 100644 --- a/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts +++ b/desktop/src/features/messages/ui/useAgentAddressLockPicker.ts @@ -14,45 +14,6 @@ import { normalizePubkey, truncatePubkey } from "@/shared/lib/pubkey"; import type { ComposerAddressAgent } from "./ComposerAddressControls"; import type { MentionSuggestion } from "./MentionAutocomplete"; -function buildMentionRemovalEdits( - text: string, - displayNames: readonly string[], - queryRange?: { start: number; end: number }, -): AutocompleteEdit[] { - const ranges = displayNames.flatMap((displayName) => - getMentionOffsets(text, displayName).map((start) => { - let end = start + `@${displayName}`.length; - if (text[end] === " ") end += 1; - return { start, end }; - }), - ); - if (queryRange) { - ranges.push({ - start: Math.max(0, Math.min(queryRange.start, text.length)), - end: Math.max(0, Math.min(queryRange.end, text.length)), - }); - } - - const merged = ranges - .filter(({ start, end }) => start < end) - .sort((left, right) => left.start - right.start) - .reduce>((result, range) => { - const previous = result.at(-1); - if (previous && range.start <= previous.end) { - previous.end = Math.max(previous.end, range.end); - } else { - result.push({ ...range }); - } - return result; - }, []); - - return merged.reverse().map(({ start, end }) => ({ - replaceFromOffset: start, - replaceToOffset: end, - insertText: "", - })); -} - export function useAgentAddressLockPicker({ applyAutocompleteEdit, audience, @@ -177,13 +138,21 @@ export function useAgentAddressLockPicker({ ], ); - const removeAddressedAgent = React.useCallback( + const unpinAddressedAgent = React.useCallback( (pubkey: string) => { const normalized = normalizePubkey(pubkey); if (!audienceScope || !normalized) return; unpinnedAgentPubkeysRef.current.add(normalized); const excludePubkey = audience.excludePubkey ?? audience.removePubkey; excludePubkey(normalized); + }, + [audience.excludePubkey, audience.removePubkey, audienceScope], + ); + const removeAddressedAgent = React.useCallback( + (pubkey: string) => { + const normalized = normalizePubkey(pubkey); + if (!audienceScope || !normalized) return; + unpinAddressedAgent(normalized); const displayName = lockedAgents.find( (agent) => agent.pubkey === normalized, )?.displayName; @@ -206,43 +175,27 @@ export function useAgentAddressLockPicker({ }, [ applyAutocompleteEdit, - audience.excludePubkey, - audience.removePubkey, audienceScope, lockedAgents, onImplicitPrefixRemoved, richText.getPlainTextAndCursor, - ], - ); - const removeAddressedAgentMentions = React.useCallback( - (pubkey: string) => { - const normalized = normalizePubkey(pubkey); - if (!audienceScope || !normalized) return; - const { text } = richText.getPlainTextAndCursor(); - const matchingDisplayNames = mentions - .getDraftMentionRefs(text) - .filter((ref) => normalizePubkey(ref.pubkey) === normalized) - .map((ref) => ref.displayName); - for (const edit of buildMentionRemovalEdits(text, matchingDisplayNames)) { - applyAutocompleteEdit(edit); - } - removeAddressedAgent(normalized); - }, - [ - applyAutocompleteEdit, - audienceScope, - mentions.getDraftMentionRefs, - removeAddressedAgent, - richText.getPlainTextAndCursor, + unpinAddressedAgent, ], ); const toggleAlwaysAddressAgent = React.useCallback( - (suggestion: MentionSuggestion) => { + ( + suggestion: MentionSuggestion, + options: { preserveMention?: boolean } = {}, + ) => { const pubkey = normalizePubkey(suggestion.pubkey ?? ""); if (!audienceScope || !pubkey || !suggestion.isAgent) return; if (lockedAgentPubkeys.has(pubkey)) { - removeAddressedAgentMentions(pubkey); + if (options.preserveMention) { + unpinAddressedAgent(pubkey); + } else { + removeAddressedAgent(pubkey); + } setAnnouncement( `Stopped automatically mentioning ${suggestion.displayName}`, ); @@ -313,9 +266,10 @@ export function useAgentAddressLockPicker({ onAddressAgentMention, onImplicitPrefixInserted, onPulseAddressLock, - removeAddressedAgentMentions, + removeAddressedAgent, richText.getPlainTextAndCursor, trackMentionAddressedAgent, + unpinAddressedAgent, ], ); diff --git a/desktop/src/features/messages/ui/useThreadAgentAudience.ts b/desktop/src/features/messages/ui/useThreadAgentAudience.ts new file mode 100644 index 00000000000..e10dad9cf06 --- /dev/null +++ b/desktop/src/features/messages/ui/useThreadAgentAudience.ts @@ -0,0 +1,36 @@ +import * as React from "react"; + +import { useKeepMentionedAgentsPinned } from "@/features/messages/lib/autoPinMentionedAgentsPreference"; +import { + initializePersistentAgentAudience, + usePersistentAgentAudience, +} from "@/features/messages/lib/persistentAgentAudience"; + +export function useThreadAgentAudience({ + isAgentPubkey, + rootTags, + scope, +}: { + isAgentPubkey: (pubkey: string) => boolean; + rootTags: readonly string[][]; + scope: string | null; +}) { + const audience = usePersistentAgentAudience(scope); + const keepMentionedAgentsPinned = useKeepMentionedAgentsPinned(); + + const rootAgentPubkeys = React.useMemo( + () => + rootTags.flatMap((tag) => { + const pubkey = tag[0] === "p" ? tag[1] : null; + return pubkey && isAgentPubkey(pubkey) ? [pubkey] : []; + }), + [isAgentPubkey, rootTags], + ); + + React.useEffect(() => { + if (!scope || !keepMentionedAgentsPinned) return; + initializePersistentAgentAudience(scope, rootAgentPubkeys); + }, [keepMentionedAgentsPinned, rootAgentPubkeys, scope]); + + return { audience, keepMentionedAgentsPinned }; +} diff --git a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx index 5b247c31f73..57f1a703d1e 100644 --- a/desktop/src/features/onboarding/ui/RuntimeIcon.tsx +++ b/desktop/src/features/onboarding/ui/RuntimeIcon.tsx @@ -19,6 +19,7 @@ const RUNTIME_LOGOS: Record = { export const PRESET_LOGOS: Record = { devin: "/harness-logos/devin.svg", omp: "/harness-logos/omp.svg", + pi: "/harness-logos/pi.svg", grok: "/harness-logos/grok.svg", opencode: "/harness-logos/opencode.svg", kimi: "/harness-logos/kimi.png", diff --git a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx index 50538868d05..b2fde755ff2 100644 --- a/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/AgentsSettingsPanel.tsx @@ -37,7 +37,7 @@ export function AgentsSettingsPanel() { className="mt-0.5 text-sm text-muted-foreground/70" data-settings-subcopy > - After you mention them once + Address selected agents in thread replies

{ + assert.equal( + harnessDescription("pi"), + "A minimal terminal coding harness, connected through the pi-acp adapter.", + ); +}); diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 9a71ff70f92..f91dada83fc 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -27,6 +27,8 @@ const HARNESS_DESCRIPTIONS: Record = { cursor: "Cursor's coding agent, connected to Buzz through its ACP server.", // Source: https://github.com/can1357/oh-my-pi omp: "A terminal coding agent with integrated development tools.", + // Sources: https://pi.dev/docs/latest, https://github.com/svkozak/pi-acp + pi: "A minimal terminal coding harness, connected through the pi-acp adapter.", // Source: https://build.x.ai (docs unavailable during research; kept // deliberately conservative). grok: "xAI's coding agent, connected to Buzz through its ACP entrypoint.", diff --git a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts index 431a415771d..a6ecf0ec569 100644 --- a/desktop/tests/e2e/global-agent-config-screenshots.spec.ts +++ b/desktop/tests/e2e/global-agent-config-screenshots.spec.ts @@ -251,7 +251,7 @@ test.describe("global agent config screenshots", () => { test("defaults render Databricks model labels without changing persisted ids", async ({ page, }) => { - const modelId = "data_workflow_tools.goose.goose-glm-5-3"; + const modelId = "system.ai.glm-5-3"; await installMockBridge(page, { globalAgentConfig: { preferred_runtime: "goose", @@ -291,6 +291,50 @@ test.describe("global agent config screenshots", () => { expect(persisted).toMatchObject({ model: modelId }); }); + test("defaults render the Fable 5.1 label without changing the persisted id", async ({ + page, + }) => { + const modelId = "databricks-claude-fable-5-1"; + await installMockBridge(page, { + globalAgentConfig: { + preferred_runtime: "goose", + provider: "databricks_v2", + model: modelId, + env_vars: {}, + }, + discoverAgentModels: { + models: [{ id: modelId, name: modelId }], + supportsSwitching: true, + selectedModel: modelId, + }, + runtimeFileConfigs: { + goose: { + provider: "databricks_v2", + model: modelId, + satisfiedEnvKeys: ["DATABRICKS_HOST"], + }, + }, + }); + + await openAiDefaultsSettings(page); + + const model = page.getByTestId("global-agent-model"); + await expect(model).toHaveText("Claude Fable 5.1"); + await expect(model).toHaveAttribute("data-value", modelId); + + const persisted = await page.evaluate(async () => + ( + window as typeof window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload: unknown, + ) => Promise; + } + ).__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_global_agent_config", null), + ); + expect(persisted).toMatchObject({ model: modelId }); + }); + test("defaults honor credentials set in the harness config file", async ({ page, }) => { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 6a1f91987de..916c3a06671 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -417,12 +417,10 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as expect(fullNpubs).toHaveLength(2); expect(new Set(fullNpubs).size).toBe(2); - await managedRow - .getByRole("button", { name: "Automatically mention carl", exact: true }) - .click(); + await managedRow.getByRole("button", { name: "Mention carl" }).click(); await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), ).toHaveCount(0); @@ -431,21 +429,18 @@ test("duplicate owned agents preserve provenance and exact pubkey selection", as await expect .poll(() => readOutgoingMentionPubkeys(page, "@carl local")) .toEqual([managedPubkey]); - await expect(input).toHaveText("@carl "); + await expect(input).toHaveText(""); - await page.getByTestId(`composer-address-lock-${managedPubkey}`).click(); await input.fill("@carl"); const reopenedDropdown = autocomplete(page); await expect(reopenedDropdown).toBeVisible(); const reopenedRelayRow = reopenedDropdown.getByTestId( `mention-suggestion-${relayPubkey}`, ); - await reopenedRelayRow - .getByRole("button", { name: "Automatically mention carl", exact: true }) - .click(); + await reopenedRelayRow.getByRole("button", { name: "Mention carl" }).click(); await expect( page.getByTestId(`composer-address-lock-${relayPubkey}`), - ).toBeVisible(); + ).toHaveCount(0); await expect( page.getByTestId(`composer-address-lock-${managedPubkey}`), ).toHaveCount(0); @@ -1442,7 +1437,7 @@ test("managed relay-profile agents with member roles can be addressed explicitly await expect(charlieRow.getByText("agent")).toBeVisible(); await charlieRow .getByRole("button", { - name: "Automatically mention charlie", + name: "Mention charlie", exact: true, }) .click(); @@ -1451,12 +1446,7 @@ test("managed relay-profile agents with member roles can be addressed explicitly await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), - ).toBeVisible(); - await expect( - page.getByRole("status").filter({ - hasText: "Automatically mentioning charlie", - }), - ).toBeVisible(); + ).toHaveCount(0); }); test("other-owned agents without a shared channel are hidden from mentions", async ({ @@ -2999,7 +2989,7 @@ test("a managed non-member agent from a DM can be addressed explicitly", async ( await expect(input.locator(".mention-chip")).toHaveCount(0); await charlieRow .getByRole("button", { - name: "Automatically mention charlie", + name: "Mention charlie", exact: true, }) .click(); @@ -3008,7 +2998,7 @@ test("a managed non-member agent from a DM can be addressed explicitly", async ( await expect(input.locator(".agent-mention-highlight")).toHaveText("charlie"); await expect( page.getByTestId(`composer-address-lock-${TEST_IDENTITIES.charlie.pubkey}`), - ).toBeVisible(); + ).toHaveCount(0); }); test("global non-member people can be selected from channel mentions", async ({ diff --git a/desktop/tests/e2e/persistent-agent-audience.spec.ts b/desktop/tests/e2e/persistent-agent-audience.spec.ts index 6cc2f22a883..d1174132c74 100644 --- a/desktop/tests/e2e/persistent-agent-audience.spec.ts +++ b/desktop/tests/e2e/persistent-agent-audience.spec.ts @@ -49,6 +49,24 @@ async function automaticallyMention( await composer.locator("[data-mention-picker-trigger]").click(); } +async function waitForMockLiveSubscription(page: Page, channelName: string) { + await expect + .poll(() => + page.evaluate( + (currentChannelName) => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: currentChannelName, + }) ?? false, + channelName, + ), + ) + .toBe(true); +} + +async function waitForTimelineSettled(page: Page) { + await expect(page.locator("[data-render-pending]")).toHaveCount(0); +} + async function openGeneral(page: Page) { await page.goto(`/#/channels/${CHANNEL_ID}`, { waitUntil: "domcontentloaded", @@ -92,6 +110,20 @@ async function pressPrimaryShiftM(page: Page) { await page.keyboard.press(`${isMac ? "Meta" : "Control"}+Shift+M`); } +async function readPersistedDraftContent(page: Page, draftKey: string) { + return page.evaluate((key) => { + for (const storageKey of Object.keys(window.localStorage)) { + if (!storageKey.startsWith("buzz-drafts.v2:")) continue; + const drafts = JSON.parse( + window.localStorage.getItem(storageKey) ?? "{}", + ) as Record; + const draft = drafts[key]; + if (draft) return draft.content ?? ""; + } + return ""; + }, draftKey); +} + async function readOutgoingMentionPubkeys(page: Page, content: string) { return page.evaluate((expectedContent) => { const signedEvent = window.__BUZZ_E2E_SIGNED_EVENTS__?.find( @@ -271,9 +303,9 @@ test("automatically mentions multiple agents from the mention picker", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); await automaticallyMention(composer, "Vogue"); @@ -284,7 +316,7 @@ test("automatically mentions multiple agents from the mention picker", async ({ composer.getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Manage mentions" }), ).toBeVisible(); }); @@ -292,21 +324,25 @@ test("keeps the composer and global automatic mention settings synchronized", as page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await composer.getByTestId("message-insert-mention").click(); - const optionsTrigger = composer.getByTestId("mention-options-trigger"); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "false"); - await composer - .getByTestId("mention-autocomplete") - .getByRole("button", { name: "Automatically mention Morgarita" }) - .click(); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); const composerToggle = composer.getByTestId( "mention-keep-agents-pinned-toggle", ); await expect(composerToggle).toHaveAttribute("data-state", "unchecked"); + const settings = composer.getByTestId("mention-options-settings"); + await expect(settings).toBeVisible(); + const settingsBox = await settings.boundingBox(); + const heading = settings.getByText("Automatically mention agents"); + const headingBox = await heading.boundingBox(); + expect(settingsBox?.width).toBeGreaterThanOrEqual(300); + expect(headingBox?.height).toBeLessThanOrEqual(20); + await composer + .getByTestId("mention-autocomplete") + .getByRole("button", { name: "Automatically mention Morgarita" }) + .click(); await expect(composerToggle).toHaveAttribute("data-state", "checked", { timeout: 1_500, }); @@ -316,8 +352,7 @@ test("keeps the composer and global automatic mention settings synchronized", as .getByRole("button", { name: "Turn off" }) .click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(optionsTrigger).toHaveAttribute("aria-expanded", "true"); - await expect(composerToggle).toHaveAttribute("data-state", "checked"); + await expect(composer.getByTestId("mention-options-settings")).toBeVisible(); await expect(composerToggle).toHaveAttribute("data-state", "unchecked", { timeout: 1_500, }); @@ -336,7 +371,6 @@ test("keeps the composer and global automatic mention settings synchronized", as composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); await composer.getByTestId("message-insert-mention").click(); - await composer.getByTestId("mention-options-trigger").click(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), ).toHaveAttribute("data-state", "unchecked"); @@ -347,7 +381,7 @@ test("keeps the composer and global automatic mention settings synchronized", as ).toHaveAttribute("aria-pressed", "false"); }); -test("hides automatic mention state while disabled without clearing the draft", async ({ +test("the disabled root composer preserves its explicit draft without audience controls", async ({ page, }) => { await installAudienceFixtures(page); @@ -355,11 +389,8 @@ test("hides automatic mention state while disabled without clearing the draft", const composer = channelComposer(page); const input = composer.getByTestId("message-input"); - await automaticallyMention(composer, "Morgarita"); - await input.type("draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + await input.fill("explicit draft text"); + await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0); await page.getByTestId("channel-management-trigger").click(); await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); @@ -368,22 +399,8 @@ test("hides automatic mention state while disabled without clearing the draft", await page.getByTestId("auxiliary-panel-close").click(); await expect(input).toHaveAttribute("contenteditable", "false"); - await expect(input).toHaveText("@Morgarita draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - - await page.getByTestId("channel-management-trigger").click(); - await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); - await page.getByTestId("channel-management-unarchive").click(); - await expect(page.getByTestId("channel-management-archive")).toBeVisible(); - await page.getByTestId("auxiliary-panel-close").click(); - - await expect(input).toHaveAttribute("contenteditable", "true"); - await expect(input).toHaveText("@Morgarita draft text"); - await expect( - composer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); + await expect(input).toHaveText("explicit draft text"); + await expect(composer.getByTestId("composer-address-locks")).toHaveCount(0); }); test("Tab inserts a one-time agent mention by default", async ({ page }) => { @@ -418,12 +435,11 @@ test("disabling automatic mentions leaves the composer empty after send", async }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId("message-insert-mention").click(); - await composer.getByTestId("mention-options-trigger").click(); const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); await expect(preference).toHaveAttribute("data-state", "checked"); await preference.click(); @@ -452,9 +468,9 @@ test("primary+Shift+M addresses the default agent, then toggles the highlighted }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); await pressPrimaryShiftM(page); @@ -500,10 +516,10 @@ test("primary+Shift+M favors the most recently mentioned eligible agent", async page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); await emitMockMessage(page, "Please ask Vogue", [AGENT_B]); - const input = channelComposer(page).getByTestId("message-input"); + const input = threadComposer(page).getByTestId("message-input"); await input.fill("draft text"); await input.press("ArrowLeft"); await input.press("ArrowLeft"); @@ -522,13 +538,13 @@ test("the mention button opens settings and can undo an address", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); await automaticallyMention(composer, "Morgarita"); const input = composer.getByTestId("message-input"); const ingress = composer.getByRole("button", { - name: "Manage automatic agent mentions", + name: "Manage mentions", }); await input.type("draft text"); @@ -536,31 +552,34 @@ test("the mention button opens settings and can undo an address", async ({ const menu = composer.getByTestId("mention-autocomplete"); await expect(menu).toBeVisible(); await expect(input).toHaveText("@Morgarita draft text"); - await page.getByTestId("mention-options-trigger").click(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), ).toBeVisible(); const layerBox = await composer .getByTestId("mention-autocomplete-layer") .boundingBox(); - const optionsBox = await page - .getByTestId("mention-options-trigger") + const settingsBox = await page + .getByTestId("mention-options-settings") .boundingBox(); + const menuBox = await menu.boundingBox(); expect(layerBox).not.toBeNull(); - expect(optionsBox).not.toBeNull(); - if (!layerBox || !optionsBox) throw new Error("Mention tray is not laid out"); - await page.mouse.click(layerBox.x + 4, optionsBox.y + optionsBox.height / 2); + expect(settingsBox).not.toBeNull(); + expect(menuBox).not.toBeNull(); + if (!layerBox || !settingsBox || !menuBox) { + throw new Error("Mention tray is not laid out"); + } + await page.mouse.click( + layerBox.x + layerBox.width / 2, + (settingsBox.y + settingsBox.height + menuBox.y) / 2, + ); await expect(menu).toHaveCount(0); await expect(input).toHaveText("@Morgarita draft text"); await ingress.click(); await expect(menu).toBeVisible(); - await expect(page.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "false", - ); + await expect(page.getByTestId("mention-options-settings")).toBeVisible(); await expect( page.getByTestId("mention-keep-agents-pinned-toggle"), - ).toHaveCount(0); + ).toBeVisible(); await ingress.click(); await expect(menu).toHaveCount(0); await expect(input).toHaveText("@Morgarita draft text"); @@ -569,16 +588,16 @@ test("the mention button opens settings and can undo an address", async ({ await expect(page.getByTestId("user-profile-panel")).toHaveCount(0); await expect( menu.getByRole("button", { - name: "Don't automatically mention Morgarita in this conversation", + name: "Don't automatically mention Morgarita in this thread", }), ).toHaveAttribute("aria-pressed", "true"); await menu .getByRole("button", { - name: "Don't automatically mention Morgarita in this conversation", + name: "Don't automatically mention Morgarita in this thread", }) .click(); - await expect(input).toHaveText("draft text"); + await expect(input).toHaveText("@Morgarita draft text"); await expect( composer.getByRole("button", { name: "Mention someone" }), ).toBeVisible(); @@ -656,7 +675,7 @@ test("always-mentioned agents remain selected without replaying their animation { timeout: 500 }, ); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Manage mentions" }), ).toBeVisible(); await expect(input).toBeFocused(); await expect(composer.getByTestId("mention-autocomplete")).toHaveCount(0); @@ -690,7 +709,7 @@ test("always-mentioned agents remain selected without replaying their animation ).toHaveCount(1); }); -test("the unfocused main composer keeps its dismissed mention menu closed through a thread send", async ({ +test("the unfocused root composer keeps its dismissed mention menu closed through a thread send", async ({ page, }) => { await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); @@ -698,12 +717,7 @@ test("the unfocused main composer keeps its dismissed mention menu closed throug const mainComposer = channelComposer(page); const mainInput = mainComposer.getByTestId("message-input"); - await automaticallyMention(mainComposer, "Morgarita"); - await mainInput.fill("@Morgarita earlier message"); - await mainInput.press("Enter"); - await expect(mainInput).toHaveText("@Morgarita "); - await mainInput.fill("@Morgarita"); - await expect(mainInput).toHaveText("@Morgarita"); + await mainInput.fill("@Mor"); await expect(mainComposer.getByTestId("mention-autocomplete")).toBeVisible(); await mainInput.press("Escape"); await expect(mainComposer.getByTestId("mention-autocomplete")).toHaveCount(0); @@ -760,9 +774,9 @@ test("pressing a mention overlay's own container keeps it open", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId("message-insert-mention").click(); const list = composer.getByTestId("mention-autocomplete"); @@ -782,7 +796,6 @@ test("pressing a mention overlay's own container keeps it open", async ({ // Same hazard on the options surface, where it needs no exotic scrollbar // setting to reproduce: the switch's label text is a container press, so the // overlay used to vanish before the forwarded click reached the switch. - await composer.getByTestId("mention-options-trigger").click(); const preference = composer.getByTestId("mention-keep-agents-pinned-toggle"); await expect(preference).toHaveAttribute("data-state", "checked"); await composer @@ -793,14 +806,14 @@ test("pressing a mention overlay's own container keeps it open", async ({ await expect(list).toBeVisible(); }); -test("the mention Options controls are reachable and operable by keyboard", async ({ +test("the mention setting is reachable and operable by keyboard", async ({ page, }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); await openThread(page); - const mainComposer = channelComposer(page); + const mainComposer = threadComposer(page); const mainInput = mainComposer.getByTestId("message-input"); await mainInput.click(); await mainInput.fill("@Mor"); @@ -821,23 +834,15 @@ test("the mention Options controls are reachable and operable by keyboard", asyn }); // Forward Tab still selects the highlighted suggestion, so Shift+Tab is the - // route into the overlay. It only reaches the Options controls if the focus - // gate treats them as composer-owned focus rather than unmounting on the - // editor's blur. + // route into the always-visible setting. The focus gate must treat it as + // composer-owned focus rather than unmounting on the editor's blur. await mainInput.press("Shift+Tab"); - const optionsTrigger = mainComposer.getByTestId("mention-options-trigger"); - await expect(optionsTrigger).toBeFocused(); - - await page.keyboard.press("Enter"); const preference = mainComposer.getByTestId( "mention-keep-agents-pinned-toggle", ); - await expect(preference).toBeVisible(); + await expect(preference).toBeFocused(); await expect(preference).toHaveAttribute("data-state", "checked"); - // The switch sits before its trigger in the expanded surface's tab order. - await page.keyboard.press("Shift+Tab"); - await expect(preference).toBeFocused(); await page.keyboard.press("Space"); await expect(preference).toHaveAttribute("data-state", "unchecked"); @@ -866,9 +871,9 @@ test("the mention Options controls are reachable and operable by keyboard", asyn // under test. await mainInput.fill("@Mor"); await expect(list).toBeVisible(); - const threadInput = threadComposer(page).getByTestId("message-input"); - await threadInput.focus(); - await expect(threadInput).toBeFocused(); + const rootInput = channelComposer(page).getByTestId("message-input"); + await rootInput.focus(); + await expect(rootInput).toBeFocused(); await expect(list).toHaveCount(0); }); @@ -907,9 +912,9 @@ test("a manual mention persists when automatic mentions are enabled", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { sendMessageDelayMs: 1_500 }); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -928,7 +933,7 @@ test("a manual mention persists when automatic mentions are enabled", async ({ await expect(autoPinConfirmation).not.toContainText( "Future messages in this channel will include this agent.", ); - await expect(autoPinConfirmation).toHaveAttribute("data-side", "right"); + await expect(autoPinConfirmation).toHaveAttribute("data-side", "left"); await expect(autoPinConfirmation.locator("span")).toHaveCSS( "white-space", "nowrap", @@ -948,8 +953,8 @@ test("a manual mention persists when automatic mentions are enabled", async ({ if (!addressControlBox || !confirmationBox) { throw new Error("Automatic mention confirmation is not laid out"); } - expect(confirmationBox.x).toBeGreaterThan( - addressControlBox.x + addressControlBox.width, + expect(confirmationBox.x + confirmationBox.width).toBeLessThanOrEqual( + addressControlBox.x, ); const turnOffAction = autoPinConfirmation.getByRole("button", { name: "Turn off", @@ -974,6 +979,9 @@ test("a manual mention persists when automatic mentions are enabled", async ({ .poll(() => readOutgoingMentionPubkeys(page, "@Morgarita hello")) .toContain(AGENT_A); + await expect(input).toHaveAttribute("contenteditable", "true", { + timeout: 2_500, + }); await input.fill("follow up"); await expect( composer.getByTestId(`composer-address-lock-${AGENT_A}`), @@ -989,9 +997,9 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1010,10 +1018,7 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ await autoPinConfirmation.getByRole("button", { name: "Turn off" }).click(); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); - await expect(composer.getByTestId("mention-options-trigger")).toHaveAttribute( - "aria-expanded", - "true", - ); + await expect(composer.getByTestId("mention-options-settings")).toBeVisible(); await expect( composer.getByTestId("mention-keep-agents-pinned-toggle"), ).toHaveAttribute("data-state", "unchecked"); @@ -1029,9 +1034,9 @@ test("the auto-pin popover can turn off automatic agent mentions", async ({ test("the auto-pin popover remains open while hovered", async ({ page }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1053,9 +1058,9 @@ test("removing the mention chip dismisses the auto-pin popover", async ({ }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1076,31 +1081,23 @@ test("removing the mention chip dismisses the auto-pin popover", async ({ await expect(autoPinConfirmation).toHaveCount(0); }); -test("automatic mentions are scoped to their channel or thread composer", async ({ +test("automatic mentions exist only in thread composers and stay thread-scoped", async ({ page, }) => { await installAudienceFixtures(page); await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await openThread(page); + const rootComposer = channelComposer(page); + await rootComposer.getByTestId("message-insert-mention").click(); await expect( - threadComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + rootComposer.getByTestId("mention-options-settings"), ).toHaveCount(0); - - await automaticallyMention(threadComposer(page), "Vogue"); - await openGeneral(page); await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), + rootComposer.getByRole("button", { name: /^Automatically mention / }), ).toHaveCount(0); await openThread(page); + await automaticallyMention(threadComposer(page), "Vogue"); await expect( threadComposer(page).getByTestId(`composer-address-lock-${AGENT_B}`), ).toBeVisible(); @@ -1111,72 +1108,119 @@ test("automatic mentions are scoped to their channel or thread composer", async ).toHaveCount(0); }); -test("a thread automatic mention preserves an explicitly unpinned root agent", async ({ +test("a root agent mention is explicit for one message and never becomes retained", async ({ page, }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page, { agentAName: "claude code" }); await openGeneral(page); - const rootComposer = channelComposer(page); - const rootInput = rootComposer.getByTestId("message-input"); - await automaticallyMention(rootComposer, "claude code"); - await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await rootComposer - .getByTestId(`composer-address-lock-remove-${AGENT_A}`) - .click(); - await expect(rootInput).toHaveText(""); - await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - - await rootInput.fill("@cla"); - await expect(rootComposer.getByTestId("mention-autocomplete")).toBeVisible(); - await rootInput.press("Tab"); - await rootInput.type("one time"); - await rootInput.press("Enter"); - await expect(rootInput).toHaveText(""); + const composer = channelComposer(page); + const input = composer.getByTestId("message-input"); + const firstRootMessage = "@claude code one time"; + await input.fill("@cla"); + await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); + await input.press("Tab"); + await input.pressSequentially(" one time"); + await expect(input).toHaveText(firstRootMessage); await expect( - rootComposer.getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await input.press("Enter"); - await openThread(page); - const activeThreadComposer = threadComposer(page); - const threadInput = activeThreadComposer.getByTestId("message-input"); - await threadInput.fill("@cla"); - await expect( - activeThreadComposer.getByTestId("mention-autocomplete"), - ).toBeVisible(); - await threadInput.press("Tab"); - await expect( - activeThreadComposer.getByTestId(`composer-address-lock-${AGENT_A}`), - ).toBeVisible(); - await threadInput.type("thread message"); - await threadInput.press("Enter"); + await expect(input).toHaveText(""); + await expect + .poll(() => + page.evaluate( + (pubkey) => + Boolean( + window.__BUZZ_E2E_SIGNED_EVENTS__?.some((event) => + (event.tags ?? []).some( + (tag) => tag[0] === "p" && tag[1] === pubkey, + ), + ), + ), + AGENT_A, + ), + ) + .toBe(true); + await input.fill("next root message"); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, "next root message")) + .toEqual([]); +}); +test("a removed root-inherited agent stays excluded after the thread reopens", async ({ + page, +}) => { + await keepMentionedAgentsPinned(page); + await installAudienceFixtures(page); await openGeneral(page); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); + await waitForMockLiveSubscription(page, "general"); - const restoredRootInput = channelComposer(page).getByTestId("message-input"); - await restoredRootInput.fill("@cla"); + const rootId = "c".repeat(64); + const rootContent = "@Morgarita root request"; + await page.evaluate( + ({ agentPubkey, content, eventId }) => { + window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content, + id: eventId, + mentionPubkeys: [agentPubkey], + }); + }, + { agentPubkey: AGENT_A, content: rootContent, eventId: rootId }, + ); + await waitForTimelineSettled(page); + + const rootRow = page + .getByTestId("message-timeline") + .locator(`[data-testid="message-row"][data-message-id="${rootId}"]`); + await expect(rootRow).toBeVisible(); + await rootRow.hover(); + await rootRow.getByRole("button", { name: "Reply" }).click(); + await expect(page.getByTestId("message-thread-panel")).toBeVisible(); + + let composer = threadComposer(page); await expect( - channelComposer(page).getByTestId("mention-autocomplete"), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); - await restoredRootInput.press("Tab"); - await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), - ).toHaveCount(0); - await restoredRootInput.type("one time"); - await restoredRootInput.press("Enter"); + await expect(composer.getByTestId("message-input")).toHaveText("@Morgarita "); + await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); + await expect(composer.getByTestId("message-input")).toHaveText(""); + + const threadPanel = page.getByTestId("message-thread-panel"); + await threadPanel.getByTestId("auxiliary-panel-close").click(); + await expect(threadPanel).toBeHidden(); + + const loadedRootTags = await page.evaluate(async (eventId) => { + const raw = await window.__BUZZ_E2E_INVOKE_MOCK_COMMAND__?.("get_event", { + eventId, + }); + return typeof raw === "string" + ? (JSON.parse(raw) as { tags?: string[][] }).tags + : null; + }, rootId); + expect(loadedRootTags).toContainEqual(["p", AGENT_A]); + + await rootRow.hover(); + await rootRow.getByRole("button", { name: "Reply" }).click(); + await expect(threadPanel).toBeVisible(); - await expect(restoredRootInput).toHaveText(""); + composer = threadComposer(page); + const input = composer.getByTestId("message-input"); await expect( - channelComposer(page).getByTestId(`composer-address-lock-${AGENT_A}`), + composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toHaveCount(0); + await expect(input).toHaveText(""); + + const reply = "plain reply after reopening"; + await input.fill(reply); + await input.press("Enter"); + await expect + .poll(() => readOutgoingMentionPubkeys(page, reply)) + .not.toContain(AGENT_A); }); test("an unchecked agent remains excluded while automatic mentions stay enabled", async ({ @@ -1184,10 +1228,10 @@ test("an unchecked agent remains excluded while automatic mentions stay enabled" }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await composer.getByTestId(`composer-address-lock-remove-${AGENT_A}`).click(); await expect(input).toHaveText(""); @@ -1208,9 +1252,9 @@ test("re-adding a deleted automatic mention restores its automatic mention state }) => { await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1246,13 +1290,13 @@ test("implicit automatic mentions stay out of persisted drafts", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - const input = channelComposer(page).getByTestId("message-input"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); + const input = threadComposer(page).getByTestId("message-input"); await input.type("draft text"); - await openThread(page); await openGeneral(page); + await openThread(page); await expect(input).toHaveText("@Morgarita draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); @@ -1266,19 +1310,7 @@ test("implicit automatic mentions stay out of persisted drafts", async ({ await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("draft text continues"); }); @@ -1286,37 +1318,23 @@ test("an authored duplicate leading mention survives draft restoration", async ( page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - await automaticallyMention(channelComposer(page), "Morgarita"); - const input = channelComposer(page).getByTestId("message-input"); + await openThread(page); + await automaticallyMention(threadComposer(page), "Morgarita"); + const input = threadComposer(page).getByTestId("message-input"); await input.pressSequentially("@Morgarita authored duplicate"); - await openThread(page); await openGeneral(page); + await openThread(page); - await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); - // Exact typed mentions now resolve on Space, so both the automatic prefix and - // the authored duplicate retain mention identity after restoration. - await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita authored duplicate"); }); @@ -1324,8 +1342,8 @@ test("typed deletion preserves an identical authored mention in drafts", async ( page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1344,20 +1362,7 @@ test("typed deletion preserves an identical authored mention in drafts", async ( waitUntil: "domcontentloaded", }); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita manual after typed deletion"); }); @@ -1365,8 +1370,8 @@ test("removing an automatic mention preserves an identical authored mention in d page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1377,20 +1382,7 @@ test("removing an automatic mention preserves an identical authored mention in d }); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita manual after removal"); }); @@ -1398,35 +1390,22 @@ test("multiple automatic mentions stay out of persisted drafts", async ({ page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); await automaticallyMention(composer, "Vogue"); await input.pressSequentially("draft text"); - await openThread(page); await openGeneral(page); - await expect(input).toHaveText("@Vogue @Morgarita draft text"); + await openThread(page); + await expect(input).toHaveText("@Morgarita @Vogue draft text"); await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const draft = ( - JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record - )[channelId]; - if (draft) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("draft text"); }); @@ -1434,8 +1413,8 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra page, }) => { await installAudienceFixtures(page); - await openGeneral(page); - const composer = channelComposer(page); + await openThread(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await automaticallyMention(composer, "Morgarita"); @@ -1444,30 +1423,18 @@ test("re-enabling an automatic mention preserves an authored duplicate after dra await automaticallyMention(composer, "Morgarita"); await input.pressSequentially("@Morgarita authored duplicate"); - await openThread(page); await openGeneral(page); + await openThread(page); - await expect(input).toHaveText("@Morgarita @Morgarita authored duplicate"); - await expect(input.locator(".agent-mention-highlight")).toHaveCount(2); + await expect(input).toHaveText("@Morgarita authored duplicate"); + await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); await page.goto(`/#/channels/${RANDOM_CHANNEL_ID}`, { waitUntil: "domcontentloaded", }); await expect(page.getByTestId("chat-title")).toHaveText("random"); await expect - .poll(() => - page.evaluate((channelId) => { - for (const storageKey of Object.keys(window.localStorage)) { - if (!storageKey.startsWith("buzz-drafts.v2:")) continue; - const drafts = JSON.parse( - window.localStorage.getItem(storageKey) ?? "{}", - ) as Record; - const draft = drafts[channelId]; - if (draft?.channelId === channelId) return draft.content ?? ""; - } - return ""; - }, CHANNEL_ID), - ) + .poll(() => readPersistedDraftContent(page, `thread:${THREAD_ROOT_ID}`)) .toBe("@Morgarita authored duplicate"); }); @@ -1475,8 +1442,8 @@ test("a restored multi-word automatic mention remains a chip with the caret afte page, }) => { await installAudienceFixtures(page, { agentAName: "claude code" }); - await openGeneral(page); - const originalComposer = channelComposer(page); + await openThread(page); + const originalComposer = threadComposer(page); await automaticallyMention(originalComposer, "claude code"); const originalInput = originalComposer.getByTestId("message-input"); await originalInput.pressSequentially("hello"); @@ -1490,9 +1457,9 @@ test("a restored multi-word automatic mention remains a chip with the caret afte waitUntil: "domcontentloaded", }); await expect(page.getByTestId("chat-title")).toHaveText("random"); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); const expectedContent = "@claude code "; await expect(input).toHaveText(expectedContent); @@ -1501,7 +1468,7 @@ test("a restored multi-word automatic mention remains a chip with the caret afte composer.getByTestId(`composer-address-lock-${AGENT_A}`), ).toBeVisible(); await expect( - composer.getByRole("button", { name: "Manage automatic agent mentions" }), + composer.getByRole("button", { name: "Manage mentions" }), ).toBeVisible(); await page.waitForTimeout(500); await expect(input.locator(".agent-mention-highlight")).toHaveCount(1); @@ -1524,9 +1491,9 @@ test("reduced motion removes addressed agents without spatial animation", async await page.emulateMedia({ reducedMotion: "reduce" }); await keepMentionedAgentsPinned(page); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("@Mor"); await expect(composer.getByTestId("mention-autocomplete")).toBeVisible(); @@ -1572,7 +1539,7 @@ test("the mention-button placement fits the narrow composer", async ({ await automaticallyMention(overlay, "Vogue"); await expect(overlay.getByTestId("composer-address-locks")).toBeVisible(); await expect( - overlay.getByRole("button", { name: "Manage automatic agent mentions" }), + overlay.getByRole("button", { name: "Manage mentions" }), ).toBeVisible(); await waitForAnimations(page); await composer.screenshot({ path: `${SHOTS}/narrow-mention-button.png` }); @@ -1581,9 +1548,9 @@ test("the mention-button placement fits the narrow composer", async ({ test("captures the lightweight auto-pin popover", async ({ page }) => { await seedTheme(page, "buzz-dark"); await installAudienceFixtures(page); - await openGeneral(page); + await openThread(page); - const composer = channelComposer(page); + const composer = threadComposer(page); const input = composer.getByTestId("message-input"); await input.fill("draft text"); await pressPrimaryShiftM(page); diff --git a/desktop/tests/e2e/send-channel-binding.spec.ts b/desktop/tests/e2e/send-channel-binding.spec.ts index 3a7fdf7613a..7854658dd0c 100644 --- a/desktop/tests/e2e/send-channel-binding.spec.ts +++ b/desktop/tests/e2e/send-channel-binding.spec.ts @@ -84,12 +84,12 @@ test("message with agent mention lands in compose-time channel despite mid-send await input.press("Enter"); await page.keyboard.type(` ${MESSAGE_TEXT}`); - // Verify the inline mention and persistent address are present before submitting. + // Verify the inline mention is present without creating thread-retained state. await expect(input).toHaveText(`@BotA ${MESSAGE_TEXT}`); await expect(input.locator(".agent-mention-highlight")).toHaveText("BotA"); await expect( page.getByTestId(`composer-address-lock-${OUT_OF_CHANNEL_BOT_PUBKEY}`), - ).toBeVisible(); + ).toHaveCount(0); // Snapshot the baseline command count before sending const baselineCommands = await readCommandLog(page); diff --git a/docs/nips/NIP-FI-CONF.md b/docs/nips/NIP-FI-CONF.md deleted file mode 100644 index c6ba387977f..00000000000 --- a/docs/nips/NIP-FI-CONF.md +++ /dev/null @@ -1,375 +0,0 @@ -NIP-FI-CONF -=========== - -Conformance evidence profile ----------------------------- - -`draft` `optional` - -**Dependencies**: NIP-FI core. Applies additionally to any claimed -NIP-FI-EDGE, NIP-FI-LIFECYCLE, and NIP-FI-DELEG profile. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and -"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 -and RFC 8174) when, and only when, they appear in all capitals. - -## Abstract - -NIP-FI core and its profiles state required behavior. This profile states what -counts as evidence that an implementation has it: the claim unit, the evidence -rules, the complete denial-fixture enumeration, mutation adequacy, and the -interoperability exit test. - -This profile is separately claimable and is never advertised in discovery: -conformance is a property of a reviewed revision, not a wire feature. It -defines no wire behavior, denial mapping, invariant, or admission rule; where -it names one, NIP-FI core or the owning profile is normative. - -## Claim unit - -A conformance claim names exactly one immutable tuple: - -```text -(implementation revision, - adapter revision, - build artifact digest, - deployment revision, - governing document revision, - exit fixture digest, - claimed profiles, - assertion_policy_id, - transport_contract_id, - enrollment mode) -``` - -Changing any element creates a new claim. Results from one tuple MUST NOT be -carried into another. A report contains every applicable oracle from core and -every claimed profile exactly once, with status `pass` or `not-applicable` -only, except that `FI-CONF-INTEROP-EXIT` alone may instead carry `deferred` -under the condition in **Interoperability exit test**. Blank, skipped, -expected-failure, and not-run results cannot support a claim -(`FI-CONF-CLAIM-COMPLETE`). - -Enrollment mode is part of the claim unit and is private: it is recorded in -the access-controlled report, never in discovery or any public artifact. - -## Evidence rules - -Each passing oracle records the claim tuple, a stable test identifier and -adapter entry point, the command with start time, end time, exit status, and -any random seed, the synthetic input or a privacy-safe digest of it, the -before-and-after authoritative state relevant to the oracle, the expected and -observed outcomes, and artifact locations with SHA-256 digests. Stateful -oracles use an isolated database or namespace and inspect committed state -rather than inferring it from a response. Concurrency oracles record every -contender and the single serialized outcome. Time-boundary oracles use a -controlled clock. - -Adapters MUST drive public or production-equivalent entry points. A storage -helper MAY inspect state or inject a dependency outage; it MUST NOT replace the -operation under test. Calling an internal authorization function without -traversing the protected ingress does not satisfy ingress coverage. - -None of the following satisfies any oracle: searching source, documentation, -schemas, or binaries for a token; asserting that a route calls a named -function; recording a test name without its execution result; using a mock to -prove a deployed network boundary; citing a check from another revision; or -marking an oracle passed because the feature is configured. - -`FI-TRACE-TOFU-THEFT` takes an access-controlled **configuration** witness -only; under the private-posture rule no discovery witness for enrollment mode -can exist. Discovery invariance is proved separately by -`FI-TRACE-DISCOVERY-PRIVATE`. - -Requirements marked `[deployment artifact: ...]` in core or a profile are -evidenced by the named access-controlled review record at the claimed -deployment revision, not by a behavioral oracle. A claim listing an artifact -without the record is incomplete. - -Reports and artifacts hold private deployment detail and MUST remain access -controlled. They MUST NOT enter public reports, examples, discovery, or -protocol output, and MUST NOT contain raw assertions, secrets, or unredacted -`iss`, `sub`, or claim values. The shared exit fixture is exempt: its values -are synthetic by construction and name no real principal, issuer, or key. - -## Denial fixtures - -`FI-TRACE-DENIAL-ORACLE` requires one fixture per **private condition**, not -one per public class; a per-class suite compares a class against itself. The -enumeration below is the required fixture set (`FI-CONF-DENIAL-FIXTURES`). The -public-class column restates NIP-FI core, which owns the mapping and the bytes. - -| # | Private condition | Public class | Defined by | -|---|---|---|---| -| 1 | assertion, proof, or delegation evidence absent | `missing_evidence` | core | -| 2 | edge provenance absent or incomplete on an edge-required route (assertion may be present) | `missing_evidence` | NIP-FI-EDGE | -| 3 | evidence present but rejected: signature, key selection, issuer, audience, time, size, ambiguity, token class, body binding, or edge provenance (present but rejected) | `evidence_rejected` | core, NIP-FI-EDGE | -| 4 | replayed evidence — committed replay identity already claimed | `authorization_denied` | core, NIP-FI-EDGE | -| 5 | `key_mismatch` — asserted key is not the proven actor | `authorization_denied` | core | -| 6 | `attestation_required` — attested-key enrollment without a matching key claim | `authorization_denied` | core | -| 7 | `binding_conflict` — either side of the active relation is taken | `authorization_denied` | core | -| 8 | `pair_retired` | `authorization_denied` | core | -| 9 | `key_revoked` | `authorization_denied` | core | -| 10 | `policy_denied` — local operation policy | `authorization_denied` | core | -| 11 | `binding_required` — enrollment policy creates no binding at this request: provisioned mode with no binding, or any unrecognized policy value | `authorization_denied` | core | -| 12 | `identity_disabled` | `authorization_denied` | NIP-FI-LIFECYCLE | -| 13 | `explicit_replacement_required` — pending lineage | `authorization_denied` | NIP-FI-LIFECYCLE | -| 14 | `binding_expired` — administrative expiry | `authorization_denied` | NIP-FI-LIFECYCLE | -| 15 | `delegation_not_current` — owner or relationship no longer current | `authorization_denied` | NIP-FI-DELEG | -| 16 | `dependency_unreadable` | `authorization_unavailable` | core | - -Private-condition names are fixture identifiers, not wire values; a deployment -MAY use other internal reason codes if every enumerated condition has a -fixture. Rows for an unclaimed profile are `not-applicable` with absence -evidence. A profile that introduces a private condition MUST add its row; an -unenumerated condition escapes this oracle entirely. - -**Enumeration agreement.** `policy_denied` and `dependency_unreadable` are the -*prose-only allowlist*: core conditions that core states in prose and does not -name symbolically. The suite MUST check mechanically at the claimed head, by -symbol and never by row number, and every check MUST be green on the unmutated -documents before any mutant is scored: - -1. every symbol core denies by name has a row here attributed to core with the - same public class; -2. the set of symbols in core-attributed rows equals core's symbolic denial set - together with the allowlist, exactly, and the allowlist is disjoint from - that set; and -3. for each claimed profile that owns a private-denial-condition table, the - set of `(identifier, public class)` pairs in that table equals the set of - pairs attributed to that profile here, exactly; a row with multiple owners - contributes its pair to each. - -If a later core names an allowlisted symbol, check 2's disjointness fails -until the allowlist entry is deleted, and check 1 validates the promoted -symbol's class. - -**Anonymity comparison.** Every `authorization_denied` row is in the -private-state anonymity set. Between two private conditions on one -implementation, every response byte as transmitted MUST agree — transfer -framing included — except values a server cannot hold constant across two -instants, such as `Date`. This is wider than the interoperability object -below: within one implementation, any byte that varies by private condition is -a disclosure, whatever field it sits in. - -**Interoperability compared object.** Between two implementations, comparison -is over what core pins and nothing more. Over Nostr: the complete relay message -excluding only the event or subscription identifier echoed from the request, -as compact JSON with no insignificant whitespace per NIP-01. Over HTTP: the -status code; the content per RFC 9110 Section 6.4, after transfer decoding with -chunk framing and trailers excluded; and the exact values of only the header -fields core's denial table names, field names matched case-insensitively per -RFC 9110 Section 5.1. `Content-Length` is not pinned. Header order and unnamed -fields are outside the object, and their values MUST NOT depend on the private -condition. A field core names that an implementation cannot hold constant MUST -be reported with the reason, and its value MUST be independent of the private -condition. If core later pins another field, it joins with no edit here. - -**Run discipline.** The oracle runs a fixed positive iteration count on a -pinned isolated runner at the exact claimed head. Before the run the operator -records the environment, public-response corpus, bounds, sampling method, -statistical rule, noise treatment, and acceptance threshold. A breach fails the -gate, MUST NOT trigger an automatic retry, and is retained and investigated -before a separately authorized rerun. - -`authorization_unavailable` is observably distinct from `authorization_denied`. -This is accepted residual: it discloses no per-principal state, and collapsing -it would make fail-closed behavior undiagnosable. - -**Negative control.** The suite MUST include an implementation deliberately -patched to vary its denial response by private condition, and it MUST fail -this oracle. - -## Mutation adequacy - -An oracle that cannot fail is untested text that reads as tested. The -denominator is the **listed oracle**: every table row whose first cell names -exactly one complete literal oracle identifier, in NIP-FI core, in each claimed -normative profile, and in this document when CONF is claimed — selected by -that cell, not by section title. It is not the set of normative sentences, RFC -2119 keywords, or invariant labels, none of which two readers enumerate alike. - -For each listed oracle the suite MUST retain at least one **mutant**: an -implementation variant that violates a requirement that oracle governs, -together with that oracle's failing output (`FI-CONF-MUTATION`). Evidence is -the exact patch identity, the oracle identifier, and the retained failure -output at the claimed head. For this document's own oracles the implementation -under test includes the conformance suite and its report; a mutant is a single -variant of the suite or report that the entry's own oracle rejects. - -While `FI-CONF-INTEROP-EXIT` is validly deferred it remains in claim -completeness but is excluded from this section's mutation and global-control -obligations, since its failing output cannot exist without the run. Both -obligations attach with the run and MUST be discharged before either -implementation's interoperable conformance claim is accepted. No other -oracle's obligation under this section is deferrable. - -Normative prose outside the oracle tables remains binding but is not a second -denominator. Prose that no listed oracle can detect is untestable text: add the -oracle that detects it, or delete it. - -1. **One at a time.** Mutants are applied singly against an otherwise - unmodified implementation, so layered defenses cannot mask each other. -2. **Attribution.** The kill MUST come from the entry's own oracle. A mutant - killed only by another oracle establishes coverage for neither. -3. **One entry per mutant.** A mutant satisfies only the entry it was selected - for, even when it also kills other oracles. -4. **Reachability.** The suite MUST witness that a fixture reaches the mutated - decision, not merely the enclosing operation. -5. **Survivors are recorded.** A mutant its named oracle fails to kill is a - defect in the specification or the suite. It is recorded with that - disposition and MUST NOT be waived or replaced by an easier mutant. - -Two global controls bound the suite. A deny-everything implementation MUST -fail every positive oracle; an allow-everything implementation MUST fail every -negative oracle. Neither substitutes for per-entry mutants. - -## Interoperability exit test - -A claim of core conformance requires evidence that the documents alone are -sufficient to build against (`FI-CONF-INTEROP-EXIT`). Two implementations that -have not shared code and have not consulted a common reference implementation -each produce, from NIP-FI core and any claimed profile documents alone: - -- one valid `client-attached` request, over WebSocket upgrade and over HTTP, - compared over its signing inputs as defined below; and -- one byte-exact public denial response for each of the four public classes, - on each transport where the class can be decided, compared over the - interoperability compared object under **Denial fixtures**. - -Independence is a claim about code, not inputs: two implementations given -different issuers, keys, or clocks cannot produce equal bytes. The run is -therefore parameterized by a **shared exit fixture** that both sides load and -neither side authors: - -- one issuer identity and one JWK set, including the private key needed to - mint assertions and the `kid` selecting it; -- one assertion per denial class and one for the valid request, each as - complete pre-signature protected-header and claim-set JSON values — - including `alg`, `typ`, `kid`, every member the policy allows, and fixed - `iss`, `sub`, `aud`, `nostr_pubkey`, `client_id`, `iat`, `exp`, and token - class; -- one Nostr secret key for the proof, with the complete unsigned event fields - for each transport — the NIP-98 event over HTTP and the NIP-42 event with - its challenge and relay values over the WebSocket upgrade — including - `created_at`; -- one frozen evaluation instant, and the skew and lifetime bounds in force; - and -- the domain, target resource, operation, and enrollment policy for each case. - -The canonical fixture is authored by this document's editors, not by any -claiming implementation, and MUST be published as a single file at -`docs/nips/fixtures/nip-fi-conf-exit.json` in the same repository as these -documents, with its SHA-256 digest, before any `FI-CONF-INTEROP-EXIT` run. -Both sides MUST load that file, MUST verify the digest before the run, and -MUST record the digest with the evidence; a run against any other fixture -instance is not `FI-CONF-INTEROP-EXIT` evidence. While the canonical fixture -is unpublished, the claim tuple's exit fixture digest records the reserved -value `pending-canonical-fixture`, valid only in a claim whose -`FI-CONF-INTEROP-EXIT` result is `deferred`. Publication changes the element -and therefore creates a new claim. - -**Request compared object.** Signature octets are excluded, because conforming -implementations need not agree on them (randomized `ES256` and fresh-aux -BIP-340 do not) and no document here pins JWS or JSON member order. The -compared object is the **signing inputs**: for each transport's Nostr proof, -the NIP-01 serialization the event id is taken over, compared against its own -transport's serialization; for the assertion, the decoded protected header and -claim set compared as JSON values with member order excluded. Every value the -compared object depends on MUST be pinned in the fixture. - -The exchanged artifact per case is the complete request and response frame on -each transport — for HTTP the request line, headers, and body and the response -status, headers, and body; for Nostr the complete client and relay messages — -so that a mismatch can be explained from fields outside the compared object. - -The test passes when outputs compare equal over their compared objects and -each implementation accepts the other's valid request and reproduces the -other's denials. Exit evidence includes the exchanged artifacts and each -implementation's statement of independence. A divergence traced to an -underspecified value is a defect in the specification, not in either -implementation, and is fixed there. - -**Negative control.** One implementation is patched to emit a denial that -differs from the other only outside the compared object — a header core does -not name, or reordered fields — and the run MUST still pass. A run that fails -this control is comparing more than core pins; the exit test is then the -defect. The control is retained with the evidence. - -`FI-CONF-INTEROP-EXIT` is REQUIRED only once a second implementation meeting -the independence conditions exists. Until then a conformance claim MUST record -it as deferred with the machine-readable reason -`no-independent-implementation`. A deferred exit test MUST be run and passed -before the second implementation's conformance claim is accepted, and the -first implementation's claim MUST be re-evidenced against that run. - -## Applicability - -`not-applicable` requires a machine-readable reason and behavioral proof that -the surface is absent: - -- edge oracles only when no trusted-edge profile is accepted, none is - advertised, and executable cases reject every trusted-edge evidence shape; -- snapshot-rotation oracles only when no local key or status snapshot source - is configured and executable evidence proves the absence; -- `FI-TRACE-TOFU-THEFT` only when TOFU is neither configurable nor configured - and executable first-use cases deny; -- `FI-TRACE-CURRENT-STATUS-STALE` and `FI-TRACE-CURRENT-STATUS-REVOKED` only - when every configured assertion policy declares freshness class - `offline-jwt` and executable cases prove a presented witness is never - consulted; -- `FI-TRACE-CAPABILITY-REVOCATION` only when no external capability - projection requiring a declared revocation bound is configured, and - executable evidence proves no assertion capability or local-policy value - claims such a bound; -- lifecycle and delegation oracles only when the profile is unclaimed, - disabled, and denied on every ingress; and -- every other oracle is required for an enforcing deployment. - -An implementation that supports an optional surface runs its oracles even when -one deployed domain does not activate it. - -## Release gate - -Before NIP-FI enforcement or discovery is enabled, reviewers verify, at one -reviewed revision, that: - -- one immutable claim tuple passes every applicable oracle other than a - validly deferred `FI-CONF-INTEROP-EXIT`; -- if the canonical fixture was published before the review, the tuple's exit - fixture digest is not `pending-canonical-fixture`; -- the protected-ingress inventory has no uncovered or competing authority; -- every listed oracle, other than a validly deferred `FI-CONF-INTEROP-EXIT`, - has a killed, attributed, reachable mutant and every survivor is recorded; -- the denial-fixture enumeration is complete for the claimed profiles and its - negative control fails as required; -- the interoperability exit test has passed against an independent - implementation, or is recorded as deferred because none exists; -- every named deployment artifact exists at the claimed deployment revision; - and -- public and operational sinks pass privacy-canary inspection. - -Documentation review, source review, and static scans are review inputs. They -close no item in this gate. - -## Behavioral oracles - -| ID | Required outcome | -|---|---| -| `FI-CONF-CLAIM-COMPLETE` | A report missing an applicable oracle, duplicating one, carrying a result from another claim tuple, claiming a status other than `pass`/`not-applicable` — or `deferred` on any oracle other than `FI-CONF-INTEROP-EXIT` — or omitting mutant evidence for any oracle other than a deferred `FI-CONF-INTEROP-EXIT`, or recording the exit fixture digest `pending-canonical-fixture` with any `FI-CONF-INTEROP-EXIT` result other than `deferred`, is rejected. | -| `FI-CONF-DENIAL-FIXTURES` | Every enumerated private condition has a fixture; core and each claimed profile pass exact identifier/class/owner enumeration agreement; anonymity-set responses compare byte-identical; the distinguishing negative control fails. | -| `FI-CONF-MUTATION` | Every listed oracle — except `FI-CONF-INTEROP-EXIT` while validly deferred, per **Mutation adequacy** — has a singly-applied, attributed, reachability-witnessed mutant killed by that entry's own oracle; the deny-everything and allow-everything global controls fail every oracle **Mutation adequacy** requires of them, with retained evidence; survivors are recorded, not waived. | -| `FI-CONF-INTEROP-EXIT` | Two independent implementations produce, from the documents alone, valid requests equal over the request compared object and per-class denials equal over the denial compared object, and accept each other's output. | - -## Security considerations - -Conformance evidence is a privileged artifact: it enumerates private denial -conditions, enrollment posture, and deployment topology that the protocol -deliberately keeps off the wire. Publishing a report, a fixture corpus, or a -mutant catalogue would disclose exactly what `FI-INV-13` and -`FI-TRACE-DISCOVERY-PRIVATE` protect. - -A passing suite bounds the behaviors it exercises and nothing else. Mutation -adequacy raises the cost of a masked defect; it does not prove absence of -defects, and a claim that cites this profile as proof of security rather than -of tested behavior is misusing it. diff --git a/docs/nips/NIP-FI-DELEG.md b/docs/nips/NIP-FI-DELEG.md deleted file mode 100644 index 9b9134c2b3d..00000000000 --- a/docs/nips/NIP-FI-DELEG.md +++ /dev/null @@ -1,186 +0,0 @@ -NIP-FI-DELEG -============ - -Delegated agent authorization profile --------------------------------------- - -`draft` `optional` `relay` - -**Protocol dependency**: NIP-FI core. - -The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and -"MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 -and RFC 8174) when, and only when, they appear in all capitals. - -## Scope - -This profile authorizes a delegate key from separately validated delegation -evidence rooted in a currently eligible NIP-FI owner binding. The delegate -proves its own key. It does not present a federated assertion and never receives -or inherits the owner's binding. Because a trusted edge inserts assertion and -provenance fields on every request it forwards, and `FI-DELEG-PATH-SEPARATION` -denies any such field on a delegated request, delegated requests cannot traverse -a route that requires edge provenance; they use ingress on which NIP-FI-EDGE is -not required. - -This profile defines the normalized delegation result and its additional -preparation, final-admission, and lease witnesses. It does not define a wire -format for creating delegation relationships; NIP-OA or another protocol may -supply the evidence if it satisfies this contract. - -## Delegation evidence - -A validator returns this closed result: - -```text -DelegationEvidence = ( - domain, - owner_key, - delegate_key, - relationship_id, - relationship_revision, - audience, - operations, - conditions, - resource_or_target, - not_before?, - mandatory_expiry -) -``` - -`relationship_id` and `relationship_revision` are deployment-local dependency -identifiers. All other fields are interoperability-critical in meaning even -when their concrete encoding belongs to the supplying delegation protocol. - -The evidence authenticates every field, has one unambiguous owner and delegate, -matches the server-owned domain and exact request or target, and has a finite -expiry satisfying `now < mandatory_expiry`; equality at an expiry is expired. -Optional `not_before` satisfies `not_before <= now + skew`, using the -configured delegated `skew`; arithmetic is overflow-safe. A missing -configured `skew` denies. The proven actor equals `delegate_key`. -[FI-DELEG-EVIDENCE-CLOSED] - -A delegated request carries fresh request-appropriate Nostr proof and no -`Nostr-Federated-Identity` or profile provenance field. Mixed direct and -delegated evidence denies rather than selecting a path. [FI-DELEG-PATH-SEPARATION] - -## Private denial conditions - -This profile defines exactly this private condition identifier and owning public -class for NIP-FI-CONF enumeration agreement: - -| Private condition identifier | Public class | -|---|---| -| `delegation_not_current` | `authorization_denied` | - -The identifier is a fixture name, not a wire value. Adding, removing, renaming, -or reclassifying it requires the same change in NIP-FI-CONF's denial-fixture -table. - -## Preparation - -Preparation resolves the exact server-owned domain, target context, operation, -resource, and proven delegate actor before validating delegation evidence. It -then atomically reads: - -- the active owner binding and exact binding version; -- every owner tombstone, key-revocation, administrative, and profile lifecycle - gate applicable to that binding; -- the exact relationship identifier and revision; -- current local policy and resource versions; and -- every invalidation dependency and deadline. - -The owner binding is current and authorization-eligible at preparation. A -cached owner lease is not authority. The requested capability is the -intersection of the delegation's operation, audience, conditions, and target -with current local policy; an unsupported operation or empty intersection -denies. [FI-DELEG-OWNER-CURRENT] - -Preparation remains read-only under `FI-INV-08`. It cannot create or change an -owner or delegate binding, identity, provenance, lifecycle fact, relationship, -last-seen value, replay claim, receipt, lease, or application effect. -[FI-DELEG-NO-BINDING] - -## Final admission - -Core final admission additionally requires: - -1. the exact delegation evidence and delegate proof remain live; -2. domain, actor, target, audience, operation, resource, and relationship match - the prepared value; -3. the exact current owner binding and binding version remain eligible; -4. relationship identity and revision remain current; -5. current capability intersection equals the prepared intersection; and -6. changed dependencies are reread and the complete delegated decision is - recomputed before atomic commit. - -Any mismatch, expiry, owner retirement, owner key revocation, owner binding -version change, relationship change, unreadable dependency, or unsupported -capability denies. Rotation makes the former owner key non-current; its -relationships do not transfer to the new key. [FI-DELEG-OWNER-CURRENT] - -The delegated path creates no owner or delegate binding and cannot consume an -enrollment opportunity. Its receipt identifies the delegate actor and exact -owner-binding and relationship dependencies without publishing identity -material. [FI-DELEG-NO-BINDING] - -## Delegated leases - -A deployment configures a positive finite delegated maximum and a non-negative -finite delegated `skew`. The lease deadline is no later than the minimum of: - -- delegation expiry; -- delegate proof or connection bound; -- owner binding administrative bound, when applicable; -- current relationship bound; -- local policy bound; -- the lease issue instant plus the configured delegated maximum; and -- any stronger owner-assertion bound the deployment requires. - -Missing finite configuration denies. Equality is expired and arithmetic is -overflow-safe. [FI-DELEG-LEASE-BOUND] - -Before each protected use, the service checks the delegate actor, owner binding -and version, relationship and revision, capability intersection, target, -resource, local policy, deadline, and invalidation state. It closes or rejects -the lease within the deployment's tested revocation-detection bound after any -owner or relationship dependency becomes ineligible. The claimed bound is no -smaller than measured worst-case detection plus enforcement delay. -[FI-DELEG-INVALIDATION-BOUND] - -Owner retirement, revocation, rotation, disablement under NIP-FI-LIFECYCLE, or -binding replacement invalidates dependent delegates on the same effective -schedule as owner authority. A delegate lease never authorizes another delegate -or owner key on the same connection. [FI-DELEG-OWNER-CURRENT] - -## Discovery - -A relay claiming this profile MAY add `"delegation": true` to the NIP-11 -`federated_identity` object only when owner-current resolution, the positive -finite maximum, uniform final admission, and all profile oracles are active. It -does not advertise relationship IDs, owner keys, private delegation protocol -names, or policy detail. [FI-DELEG-DISCOVERY] - -## Behavioral oracles - -| ID | Required outcome | -|---|---| -| `FI-DELEG-EVIDENCE-CLOSED` | Valid closed evidence passes; unauthenticated, ambiguous, wrong-domain/actor/target/audience, not-yet-valid, and expiry-equality variants deny. | -| `FI-DELEG-PATH-SEPARATION` | Delegation plus any direct assertion/provenance field denies; neither path falls back to the other. | -| `FI-DELEG-OWNER-CURRENT` | Exact current owner succeeds; retirement, revocation, rotation, replacement, stale owner version, stale relationship, and unreadable owner state deny without inheritance. | -| `FI-DELEG-NO-BINDING` | Successful, denied, and concurrent delegated requests create or change no owner/delegate binding or lifecycle state. | -| `FI-DELEG-LEASE-BOUND` | Every authority bound and equality boundary closes the lease; absent finite maximum denies. | -| `FI-DELEG-INVALIDATION-BOUND` | Measured owner/relationship revocation closes prepared evidence and live leases within the claimed detection bound. | -| `FI-DELEG-DISCOVERY` | Discovery is false/absent until the complete active profile passes; public output contains no relationship or owner detail. | - -NIP-FI-CONF defines evidence packaging and mutation adequacy. Each uppercase -requirement above names the oracle that detects its violation. - -## Security considerations - -Delegation expands authority only by intersection and never by copying owner -capabilities. A stolen delegation still requires the delegate key. A stolen -delegate key is bounded by the relationship and finite lease. Owner rotation -cannot silently transfer delegation because the exact owner key and binding -version are dependencies. Implementations should invalidate by dependency index -rather than wait for incidental delegate traffic. diff --git a/docs/nips/NIP-FI-EDGE.md b/docs/nips/NIP-FI-EDGE.md deleted file mode 100644 index 1f54d36e37d..00000000000 --- a/docs/nips/NIP-FI-EDGE.md +++ /dev/null @@ -1,417 +0,0 @@ -# NIP-FI-EDGE: Trusted Edge Profile - -`draft` `optional` - -## Scope - -This profile lets a trusted enterprise edge deliver federated assertion evidence to -a NIP-FI verifier. It defines two constructions: - -- `trusted-proxy-hmac-v2`, a portable request-bound HMAC envelope; and -- a private authenticated-edge assertion adapter, for platforms that provide an - equivalent closed trust boundary without the stock envelope. - -NIP-FI-EDGE is optional. A deployment can implement NIP-FI core using only -`client-attached`. Claiming this profile does not weaken core assertion validation, -independent Nostr proof, binding, lifecycle, policy, final-admission, or lease rules. -The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, -**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **NOT RECOMMENDED**, **MAY**, and -**OPTIONAL** are to be interpreted as described in BCP 14 when, and only when, -they appear in all capitals. - -Every identifier this document serializes on the wire — header names, the -profile identifier, provenance envelope fields, and proof transport codes — is -interoperability-critical. `transport_contract_id` remains deployment-local as -core classifies it: its value is opaque outside a deployment, while the -canonical contract semantics this profile contributes to it are normative and -fixed here. Local adapter revision identifiers and key identifiers are -deployment-local and MUST NOT appear in public discovery. - -## Common trusted-edge requirements - -Server-owned listener, route, and authorization-domain configuration selects exactly -one edge profile before protected traffic is accepted. Request evidence cannot -select, negotiate, or downgrade that profile. Missing, repeated, comma-combined, -malformed, oversized, mixed-profile, or profile-inconsistent evidence denies without -fallback to `client-attached` or another edge profile. - -Every trusted edge MUST: - -1. strip every inbound copy of each `Nostr-Federated-Identity`, - `Nostr-Federated-Identity-Provenance`, and `Nostr-Federated-Identity-Client-Peer` - field, and of every other edge profile's assertion, identity, capability, - provenance, and client-peer field, before inserting its own fields. A trusted - edge MUST NOT remove or modify the `Authorization` field, which remains reserved - for the independent NIP-98 proof and MUST reach final admission unmodified; -2. cryptographically authenticate the immediate edge to the accepting origin and - isolate the origin from direct or alternate ingress; -3. integrity-protect every request component used by authorization other than - an independent Nostr proof, which is protected by its own signature; -4. apply a positive finite provenance deadline that is included in final admission - and every resulting lease; -5. validate a closed upstream identity and authorization claim set and produce the - same normalized assertion result required by core; -6. preserve the server-resolved domain, operation, resource, method, authority, - path/query, body semantics, proof transport, and Nostr actor key through final - admission; and -7. keep assertions, credentials, signatures, MACs, raw client addresses, and private - claims out of URLs, public protocol output, logs, metrics, and traces. - -Header presence, source address, private-network location, hostname, or reachability -alone is not provenance. Accepting unsigned identity or capability headers, or -accepting signed headers without authenticating and isolating the immediate caller, -is nonconformant. A trusted edge that strips, rewrites, or reorders the -`Authorization` field, or that admits a proof-transport-`0x02` request whose -`Authorization` field did not arrive at the verifier byte-identical to the -client-sent value, is nonconformant. - -An adapter's reviewed contract MUST identify its accepting origins, direct-origin -controls, field-stripping point, immediate-caller authentication, protected request -components, upstream assertion and policy validation, freshness bounds, independent -Nostr-proof path, compromise impact, and conformance evidence. It MUST deny when any -part of this boundary is absent or unreadable. - -### Authenticated-edge assertion adapters - -A deployment MAY install a private authenticated-edge adapter instead of HMAC-v2. -The adapter MUST satisfy all common requirements and demonstrate together: -origin isolation, cryptographically authenticated immediate caller, inbound-field -stripping, integrity of the complete authorization-relevant request, bounded -assertion and policy freshness, no direct-origin fallback, and the core final- -admission path with independent Nostr proof. - -The adapter maps only its closed, validated claim set into the normalized result. -An opaque edge token is acceptable only inside this complete contract; opacity does -not make an unchecked header authoritative. Vendor names, issuer details, caller -identities, private field names, capability semantics, and adapter identifiers MUST -NOT appear in NIP-11 or portable examples. - -## `trusted-proxy-hmac-v2` - -The stock profile identifier is `trusted-proxy-hmac-v2`. Core computes the -`transport_contract_id` from a canonical contract that includes this profile's exact -wire format, protected components, replay rules, deadline rules, configured code -meanings, and adapter semantics. Changing any of those inputs produces a different -contract identity; the profile identifier itself remains stable. The proxy strips -all inbound assertion, provenance, and client-peer fields and inserts exactly one of -each: - -```text -Nostr-Federated-Identity: Bearer -Nostr-Federated-Identity-Provenance: v2... -Nostr-Federated-Identity-Client-Peer: -``` - -The assertion field follows core's compact-JWS and size rules. `timestamp` is -canonical unsigned decimal without leading zeroes, except zero is `0`. `nonce` and -`mac` are canonical unpadded base64url. Padding, the standard base64 alphabet, -ignored whitespace, or another encoding denies. The proxy generates a fresh nonce -containing at least 128 bits from a cryptographically secure random source. The -decoded MAC is exactly 32 octets. Finite field and decoded-nonce maxima are applied -before decoding, replay lookup, hashing, or allocation. - -`client-peer` is at most 64 ASCII octets. IPv4 uses dotted decimal with no leading -zeroes. IPv6 uses lowercase RFC 5952 text. The edge converts an observed IPv4-mapped -IPv6 address to canonical IPv4 before constructing the field; a textual mapped IPv6 -field is noncanonical. Empty, repeated, comma-combined, whitespace-padded, non-IP, -or noncanonical values deny. After verification, the verifier MAY retain only a -domain-separated keyed digest of this value in bounded private state. - -The profile uses HMAC-SHA-256 with a deployment secret containing at least 256 bits. -Let `LP(x) = uint64be(len(x)) || x`, where length is in octets. The literal prefix is -14 ASCII octets and is not length-prefixed. The pre-MAC input is exactly: - -```text -"NIP-FI-PROXY-2" || -LP(timestamp_u64be) || LP(nonce_bytes) || LP(SHA256(jwt_ascii)) || -LP(authorization_domain_id) || -LP(method_ascii) || LP(authority_ascii) || LP(path_and_query_ascii) || -LP(SHA256(payload_octets)) || LP(proof_transport_octet) || LP(client_peer_ascii) -``` - -`mac = HMAC-SHA-256(secret, pre_mac_input)`. The transmitted `mac` is canonical -unpadded base64url of the raw 32-octet result. The verifier compares it in constant -time. - -### Canonical components - -- **Timestamp:** Parse canonical decimal into an unsigned 64-bit integer, rejecting - overflow, then serialize it as exactly eight-byte big-endian. Freshness checks are - separate from serialization. -- **Nonce:** Decode the exact canonical base64url field before serialization. -- **Assertion:** Hash the exact ASCII compact-JWS octets after the one space in - `Bearer `. No whitespace, Unicode, JSON, or base64 normalization is allowed. -- **Authorization domain:** Configuration contains a canonical lowercase, - hyphenated RFC 9562 UUID named `authorization_domain_uuid`. Parse its 32 displayed - hexadecimal digits into the exact 16 UUID octets in display/network order. For - example, `00112233-4455-6677-8899-aabbccddeeff` becomes - `00112233445566778899aabbccddeeff`. UTF-8 UUID text, hashing, truncation, - namespace derivation, mixed-endian GUID encoding, uppercase, and unhyphenated - configuration are forbidden. The UUID is generated once, is immutable for the - domain's lifetime, and is shared through authenticated proxy/verifier - configuration. Duplicate UUIDs among active domains MUST fail startup. -- **Method:** Use the exact uppercase ASCII method token after trusted route - resolution. Lowercase or noncanonical input denies; the verifier does not repair it. -- **Authority:** Use server-configured lowercase ASCII host plus explicit effective - decimal port. IPv6 uses brackets and RFC 5952. Userinfo, a trailing dot, an omitted - port, percent encoding, or an authority derived solely from `Host`, `Forwarded`, or - `X-Forwarded-Host` denies. -- **Path and query:** Use the exact post-rewrite ASCII origin-form. Empty path becomes - `/`; a present query includes `?`. Percent octets and hex case, an empty query, - repeated names, and parameter order are preserved. No decoding, sorting, - dot-segment removal, or re-encoding may occur after the edge snapshot. An - unaccounted rewrite denies. -- **Payload:** Hash the complete HTTP payload octets after transfer-coding removal and - before content-coding decompression. These are exactly the octets forwarded by the - edge and exposed to verification. HTTP framing, chunk delimiters, and trailers are - excluded; `Content-Encoding` is not decoded. A WebSocket upgrade uses the empty - payload. Substitution of the protected octets after the snapshot denies. -- **Proof transport:** Serialize exactly one assigned octet from the registry below. -- **Client peer:** Serialize the exact canonical ASCII field value. - -No authorization decision, target, resource, capability, or effect selector -derives from any request or connection component outside the protected pre-MAC -components, except an independent Nostr proof validated on its own signature, -such as the NIP-98 event in `Authorization` or the NIP-42 event after connect, -which the MAC does not protect; body interpretation follows the server-resolved -body semantics, never unprotected transport metadata such as `Content-Type` or -`Content-Encoding`. - -### Freshness, replay, and key rotation - -The deployment configures a positive finite `maximum_provenance_age` and a -non-negative finite `future_skew`. Evidence is live exactly when, using overflow-safe -comparisons: - -```text -timestamp <= now + future_skew -now < timestamp + maximum_provenance_age -``` - -Equality at the age bound is expired. A direct lease deadline is no later than -`timestamp + maximum_provenance_age` and every core assertion, proof, policy, and -state deadline. - -Absent, malformed, stale, future-dated, wrong-key, or mismatched provenance denies. -On a route that requires edge provenance, absent or incomplete provenance — -including provenance that omits the proxy-authenticated end-client peer — maps -to the `missing_evidence` public class, regardless of whether an assertion is -present. Provenance that is present and complete but fails verification maps to -`evidence_rejected`. -A v1 envelope denies. A verifier MAY try only a configured finite set of active -secrets. Rotation does not change nonce identity: replay uniqueness is scoped to -`(authorization_domain_id, trusted-proxy-hmac-v2, nonce)` and is independent of the -secret that verifies the MAC. A committed nonce is retained through at least -`timestamp + maximum_provenance_age`. - -Preparation consumes neither nonce nor Nostr-proof replay identity. Final admission -atomically consumes both with any enrollment, receipt, and authorization decision. -A failed or rolled-back admission consumes neither. Two concurrent admissions with -the same nonce commit at most one authorization. The proxy-to-verifier hop still -requires confidentiality and integrity. - -## Proof-transport code registry - -| Code | Meaning and allocation policy | -|---|---| -| `0x00` | Invalid; MUST deny. | -| `0x01` | NIP-42 connection proof. | -| `0x02` | NIP-98 HTTP proof. | -| `0x03` | Git smart-HTTP session proof: the proxy verifies a session-scoped Nostr authorization for a Git smart-HTTP request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | -| `0x04` | Blossom media proof: the proxy verifies a Blossom media-HTTP authorization event for the request before forwarding. Reserved; allocation completes on publication of its transport contract (see below). | -| `0x05`–`0x7f` | Unassigned; allocation requires a published stable specification. | -| `0x80`–`0xfe` | Private use under an explicit shared proxy/verifier contract only. | -| `0xff` | Reserved for a future extended encoding; invalid in HMAC-v2. | - -An allocation MUST define exact proof validation, request binding, freshness, replay -identity and window, and conformance vectors. Assigned semantics never change; an -incompatible meaning receives a new code. Unknown, unconfigured, or private-use -codes without the same configured contract at proxy and verifier deny. Private-use -codes MUST NOT be advertised as portable NIP-FI-EDGE interoperability. - -Codes `0x03` and `0x04` are reserved to fix their meanings and prevent -reassignment; their transport contracts are not yet published, so their -allocations are not complete. Until the contract for such a code is published, -the code is valid only under an explicit shared proxy/verifier contract, -exactly as for private use, and MUST NOT be presented as portable NIP-FI-EDGE -interoperability. - -## Bounded payload acquisition - -Every protected `(authorization_domain_id, route, proof_transport_code)` tuple MUST -configure a finite `maximum_payload_octets` and finite per-request -`maximum_spool_octets >= maximum_payload_octets`. Zero is allowed only for a route -that requires an empty payload. Proxy and verifier configuration MUST agree and is -part of the transport contract. - -If trusted `Content-Length` exceeds the route limit, the edge denies before reading, -hashing, JWT verification, replay lookup, or authoritative mutation. For absent, -unknown, or streamed length, acquisition uses a bounded counter and spool and stops -on octet `limit + 1`. Incremental SHA-256 is allowed, but no digest or prefix can -authorize until EOF proves completeness. - -Spooling uses memory or access-controlled temporary storage with finite per-request -and aggregate quotas, cleanup on every outcome, no public or log output, and no reuse -across requests. Quota exhaustion fails closed and creates no nonce claim, proof -claim, receipt, lease, or application mutation. At or below the limit, the exact -captured payload is replayed unchanged. HMAC verification and core final admission -complete before application effects. Forwarding to a rollback-safe private spool is -not an application effect; forwarding to a parser, decoder, handler, or origin that -can act is. - -A content decoder, multipart parser, Git/Blossom handler, framework, or intermediary -that cannot expose and replay the exact stage defined above before effects cannot -claim HMAC-v2 for that route. It MUST use core `client-attached` or another specified -edge profile, never a partial-body MAC. - -## Normative HMAC-v2 vectors - -All vector integers and lengths are big-endian. Common values are: - -```text -secret_hex = 000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f -nonce_hex = 000102030405060708090a0b0c0d0e0f -nonce_base64url = AAECAwQFBgcICQoLDA0ODw -authorization_domain_uuid = 00112233-4455-6677-8899-aabbccddeeff -authorization_domain_id_hex = 00112233445566778899aabbccddeeff -jwt_ascii = eyJhbGciOiJFUzI1NiIsInR5cCI6Im5pcC1maStqd3QifQ.eyJpc3MiOiJodHRwczovL2lkLmV4YW1wbGUiLCJzdWIiOiIxMjMifQ.c2ln -assertion_digest_hex = 6103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f -``` - -The fixture JWT represents a separately minted `nip-fi+jwt` assertion and is opaque -test input; its deliberately synthetic signature is not an assertion-validation -vector. Implementations MUST reproduce each field, complete pre-MAC input, -diagnostic input digest, raw MAC, and wire MAC exactly -(`FI-TRACE-EDGE-VECTORS`). - -### Vector 1: HTTP / NIP-98 / non-empty payload - -```text -timestamp_decimal = 1700000000 -timestamp_u64be_hex = 000000006553f100 -method_ascii = POST -authority_ascii = api.example:443 -path_and_query_ascii = /upload?part=1&part=2&x=%2F -payload_hex = 68656c6c6f0a -body_digest_hex = 5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03 -proof_transport_hex = 02 -client_peer_ascii = 203.0.113.9 -pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000004504f5354000000000000000f6170692e6578616d706c653a343433000000000000001b2f75706c6f61643f706172743d3126706172743d3226783d25324600000000000000205891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03000000000000000102000000000000000b3230332e302e3131332e39 -pre_mac_input_sha256 = df2870230d2170595dccd17d9e61a82282d8cd8b978ac18bff07419ed59091d5 -mac_hex = 761d3ecbf609f0f558b4a02a1a18a25070f3dbe89fce9cac59a80bce4436ade5 -mac_base64url = dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU -provenance = v2.1700000000.AAECAwQFBgcICQoLDA0ODw.dh0-y_YJ8PVYtKAqGhiiUHDz2-ifzpysWagLzkQ2reU -``` - -### Vector 2: WebSocket / NIP-42 / empty payload / mapped peer - -The edge observed `::ffff:192.0.2.128` and emitted canonical `192.0.2.128`. - -```text -timestamp_decimal = 1 -timestamp_u64be_hex = 0000000000000001 -method_ascii = GET -authority_ascii = relay.example:443 -path_and_query_ascii = / -payload_hex = -body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -proof_transport_hex = 01 -client_peer_ascii = 192.0.2.128 -pre_mac_input_hex = 4e49502d46492d50524f58592d32000000000000000800000000000000010000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff0000000000000003474554000000000000001172656c61792e6578616d706c653a34343300000000000000012f0000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000101000000000000000b3139322e302e322e313238 -pre_mac_input_sha256 = 67564d241499491b3ea53b31d6111fbc9efac37a294f6ce591519e4bf21b53e9 -mac_hex = f71a179a018637a0582cf3de39ccb7b976216c18ada312127d4c983c14af4b20 -mac_base64url = 9xoXmgGGN6BYLPPeOcy3uXYhbBitoxISfUyYPBSvSyA -``` - -### Vector 3: IPv6 authority and path/query byte preservation - -```text -timestamp_decimal = 1700000000 -timestamp_u64be_hex = 000000006553f100 -method_ascii = GET -authority_ascii = [2001:db8::1]:443 -path_and_query_ascii = /a%2Fb?b=2&a=1&a=0 -payload_hex = -body_digest_hex = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -proof_transport_hex = 02 -client_peer_ascii = 2001:db8::2 -pre_mac_input_hex = 4e49502d46492d50524f58592d320000000000000008000000006553f1000000000000000010000102030405060708090a0b0c0d0e0f00000000000000206103b52a52730bc065d65673247603a63c9810488c90d0ada3d8d227eee5285f000000000000001000112233445566778899aabbccddeeff000000000000000347455400000000000000115b323030313a6462383a3a315d3a34343300000000000000122f61253246623f623d3226613d3126613d300000000000000020e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855000000000000000102000000000000000b323030313a6462383a3a32 -pre_mac_input_sha256 = 8a93a29c4ac30b0f2551d346d0636040b639bb1f109d287e93ce44ddaed73e33 -mac_hex = df2936f81d752f3d6bac2a36d3381c38db2c9abc3570236cb121274ad34a6161 -mac_base64url = 3yk2-B11Lz1rrCo20zgcONssmrw1cCNssSEnStNKYWE -``` - -### Serialization and negative matrix - -The following timestamp values MUST serialize as shown before freshness evaluation: - -| Decimal | `uint64be` hex | -|---:|---| -| `0` | `0000000000000000` | -| `1` | `0000000000000001` | -| `255` | `00000000000000ff` | -| `256` | `0000000000000100` | -| `18446744073709551615` | `ffffffffffffffff` | - -`00`, `01`, `+1`, surrounding whitespace, negative values, and -`18446744073709551616` deny before MAC comparison. The maximum value above is an -encoding vector; ordinary freshness policy will reject it. - -Every implementation MUST run these normative negative cases: - -| Class | Required cases and result | -|---|---| -| Envelope | Absent/repeated/comma-combined fields, `v1`, missing/extra component, padding, alternate alphabet, nonce below 16 octets or above configured max, and MAC lengths 31 or 33 all deny. | -| Domain | Uppercase/nonhyphenated UUID config fails configuration; mixed-endian UUID bytes or any one-bit domain transplant fails the baseline MAC; duplicate active UUID fails startup. | -| Request | Mutating assertion, method, authority, path/query, body, proof code, or peer while retaining Vector 1's MAC denies. | -| Metadata | Mutating `Content-Type` or `Content-Encoding` in flight changes no authorization decision, target, capability, or effect selector; a request whose server-resolved body semantics no longer hold denies. | -| Path | `%2F`→`%2f`, decoding to `/`, reordering repeated query values, or adding/removing an empty `?` fails the baseline MAC. | -| Authority | Unbracketed or non-RFC-5952 IPv6, uppercase host, trailing dot, or missing port denies before MAC comparison. | -| Peer | Textual `::ffff:192.0.2.128`, padded IPv4, uppercase/noncanonical IPv6, or whitespace denies before MAC comparison. | -| Proof | `0x00`, `0xff`, unknown stock code, or private code without a shared configured contract denies. | -| Body | Known and unknown lengths `0`, `limit-1`, and `limit` may proceed only after EOF; `limit+1`, disconnect before EOF, aggregate-quota exhaustion, or any post-snapshot substitution of the protected octets denies with no replay or authoritative mutation. | -| Replay | Concurrent final admissions of one valid envelope commit at most one; preparation and failed final admission consume none; secret rotation does not create a new nonce namespace. | -| Fallback | Direct ingress, mixed evidence, and failed HMAC never retry as `client-attached` or another adapter. | - -## Discovery and conformance - -A relay that completely implements the stock profile MAY add exactly -`"edge_transports": ["trusted-proxy-hmac-v2"]` inside the top-level NIP-11 -`federated_identity` object. `edge_transports` is an array of unique ASCII string -profile identifiers in ascending bytewise order; this document assigns only the -single value shown. A relay that does not completely implement the stock profile -MUST omit the member. It MUST NOT advertise private adapters, keys, domains, field -names, or code contracts. No request may select behavior from this discovery -member; server-owned configuration selects the edge profile. Claiming FI-EDGE -requires every configured edge profile to pass the applicable core conformance suite -and these profile traces: - -| Trace | Required oracle | -|---|---| -| `FI-TRACE-EDGE-VECTORS` | Reproduce all three normative vectors field-for-field, including each complete pre-MAC input, diagnostic input digest, raw MAC, and wire MAC; reproduce all five timestamp serialization rows; every listed serialization and negative-matrix case produces its required denial or configuration failure. | -| `FI-TRACE-PROXY-SPOOF` | Direct ingress, unsigned/header-only identity, unauthenticated caller, or invalid provenance denies without fallback. | -| `FI-TRACE-PROXY-REPLAY` | Two HMAC-v2 final admissions using one nonce commit at most one; preparation consumes neither. A private adapter proves its declared replay semantics. | -| `FI-TRACE-PROXY-CROSS-REQUEST` | Each protected component mutation denies. HMAC-v2 covers assertion, domain, method, authority, path/query, complete body, proof transport, and peer. On a `0x02` route the `Authorization` bytes at final admission equal the client-sent bytes, witnessed at both points; an edge that substitutes a valid proof from the same actor fails the witness. | -| `FI-TRACE-EDGE-BODY-BOUNDS` | Known and streamed boundary cases prove bounded work/storage, EOF completeness, cleanup, and no pre-authorization effect. | -| `FI-TRACE-EDGE-KEY-ROTATION` | A finite active-key set accepts an intended overlap without allowing nonce reuse or an unknown key. | - -The conformance record binds the exact implementation, adapter, deployment, -assertion policy, transport contract, configured code meanings, and vector revision. -Two HMAC-v2 implementations interoperate only when they reproduce all valid vector -bytes exactly, reject every negative, agree on UUID and code configuration, and -preserve atomic replay and bounded complete-body behavior. - -## Security considerations - -HMAC-v2 limits header spoofing, replay, and cross-request transplantation only when -its secret remains confidential, the edge snapshots the final routed request, the -origin authenticates that edge, and final admission atomically consumes replay state. -It does not replace TLS or independent Nostr proof. A compromised edge or shared -secret can forge federated evidence within its configured domains; use distinct -secrets and UUIDs to limit blast radius. - -Authenticated-edge adapters intentionally shift more proof to deployment controls. -A hostname, private network, or opaque token is not an equivalent construction unless -the complete boundary obligations above are demonstrated. Body buffering and replay -state are attacker-controlled resource surfaces, so all field, payload, spool, -aggregate, key-set, and retention bounds fail closed. diff --git a/docs/nips/NIP-FI-LIFECYCLE.md b/docs/nips/NIP-FI-LIFECYCLE.md deleted file mode 100644 index fdd08d4dfb3..00000000000 --- a/docs/nips/NIP-FI-LIFECYCLE.md +++ /dev/null @@ -1,269 +0,0 @@ -# NIP-FI-LIFECYCLE: Binding Lifecycle Profile - -`draft` `optional` - -## Abstract - -This profile extends NIP-FI with provisioned enrollment, identity disablement, -re-enablement, and an administrative binding-expiry gate. It is for deployments -whose binding changes require separately authorized operator or enterprise -workflows. It does not change NIP-FI assertion validation, Nostr proof, final -admission, or public denial semantics. - -The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, -**SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this -document are to be interpreted as described in BCP 14 when, and only when, they -appear in all capitals as shown here. - -## Dependencies and claim - -An implementation of this profile implements NIP-FI Core and advertises only -the boolean `"lifecycle": true` inside its NIP-11 `federated_identity` object. -This boolean claims support for this profile; it deliberately reveals neither an -enrollment mode nor lifecycle state. For a fixed set of claimed profiles, the -complete discovery output MUST be byte-identical whether enrollment is -attested-key, TOFU, or provisioned and whether lifecycle facts exist. A server -MUST NOT advertise the claim until every protected ingress in the advertised -authorization domain applies this profile through the same final-admission -authority (`FI-LC-CLAIM`). - -This profile contributes lifecycle dependencies and deadlines to the core -prepared decision and lease. They compose with core dependencies by set union; -the earliest applicable deadline wins. This profile cannot weaken, replace, or -bypass a core check. - -## Additional state - -For authorization domain `D`, this profile adds: - -```text -X_D : set of disabled identities -Q_D : identity -> pending lineage - -PendingLineage = ( - identity, - old_key, - old_binding_version -) -``` - -It also permits a core binding to carry `binding_not_after`, an optional -administrative deadline. The pending lineage names one exact retired pair and -binding version. There is at most one pending lineage per identity. - -`binding_not_after` bounds the grant represented by **one binding**; it is not a -bound on the identity. Rotation continues the same grant: the replacement binding -preserves the carried bound, and only expiry authority changes it. Retirement, -revocation, and disablement end the existing grant. Re-enablement, provisioning, -and ordinary enrollment establish a *new* grant that carries no prior bound — so -retirement of a bound pair followed by ordinary enrollment under `attested-key` -or `tofu` policy yields an unbounded binding, and that is conformant. A deadline that must survive the end -of a grant — an identity-scoped access bound — belongs in the capability -projection of authoritative local-policy state, which core already requires for -any projection whose removal must close authority within a declared bound and -which is reread at preparation, final admission, and every protected lease use. -`binding_not_after` is not that mechanism and cannot substitute for it: it does -not survive the end of the grant that carries it, so a deployment relying on it -for identity-scoped expiry cannot claim a revocation bound for that expiry -(`FI-LC-ADMIN-EXPIRY`). - -A binding carrying a reached bound is **active** for every core relation and -eligibility test in this profile and in core, including the core partial -bijection and `TargetEligible` below. It is ineligible for authorization, not -absent from the binding relation. - -`X_D`, `Q_D`, and `binding_not_after` are deployment-local state. Their versions -are revalidation dependencies, not contract identities. A change invalidates a -prepared decision and every dependent lease unless complete final-admission -recomputation produces the required current result. - -Ordinary authorization MUST deny when its identity is disabled, when pending -lineage exists for that identity, or when `now >= binding_not_after`; it MUST -NOT clear, consume, or alter any of those facts (`FI-LC-ORDINARY-GATES`). An -absent `binding_not_after` has no administrative expiry. Assertion `exp`, -`iat`, refresh, or maximum age never creates, renews, extends, or clears it. -Time passage alone creates no tombstone, lineage, or history. - -## Private denial conditions - -This profile defines exactly these private condition identifiers and owning -public classes for NIP-FI-CONF enumeration agreement: - -| Private condition identifier | Public class | -|---|---| -| `identity_disabled` | `authorization_denied` | -| `explicit_replacement_required` | `authorization_denied` | -| `binding_expired` | `authorization_denied` | - -The identifiers are fixture names, not wire values. Adding, removing, renaming, -or reclassifying one requires the same change in NIP-FI-CONF's denial-fixture -table. - -## Common transition contract - -Each transition below requires privileged authority distinct from an ordinary -federated assertion and Nostr proof. That authority MUST be bound to the exact -`D`, transition name, identity, request, old binding version when present, and -target key when present (`FI-LC-AUTHORITY`). The deployment defines how that -authority is obtained; role names, approval count, and operator APIs are out of -scope. - -A transition MUST, in one atomic commit: - -1. validate that privileged authority and fresh target-key evidence; -2. read and recheck the applicable core binding relation, retired pairs, - revoked keys, `X_D`, `Q_D`, policy, and dependency versions; -3. apply exactly the state changes specified below; -4. append immutable lifecycle history identifying the transition and versions; - and -5. advance lifecycle state so dependent prepared decisions and leases cannot - authorize after commit. - -A stale precondition, denied transition, unreadable dependency, or failed commit -MUST leave all authoritative state unchanged (`FI-LC-ATOMIC`). Lease -invalidation MAY be delivered asynchronously, but authorization use after the -commit MUST recheck the advanced dependency before allowing an operation. - -Every transition that creates a binding MUST state whether it **continues** an -existing grant, and therefore preserves that grant's administrative bound, or -**establishes** a new grant carrying no prior bound. The two cases partition the -binding-creating transitions with no remainder: core rotation continues, and -provisioning, re-enablement, and ordinary enrollment establish. A profile that -adds a binding-creating transition without this declaration cannot claim -conformance (`FI-LC-CLAIM`). - -`TargetEligible(i, k, allow_disabled)` means that `k` is not revoked, `(i, k)` -is not retired, neither `i` nor `k` has an active binding, and `i` is not -disabled unless `allow_disabled` is true. Every new target key requires fresh, -request-bound Nostr proof by that key. If domain policy requires issuer key -attestation, the transition also requires a current assertion for `i` whose key -claim equals `k`. Supplied stale, absent, wrong-identity, or mismatched required -attestation denies; it is never ignored as optional evidence -(`FI-LC-TARGET-PROOF`). - -A replacement binding records `attested-key` provenance only when current -matching issuer attestation was validated; otherwise it records `provisioned`. -TOFU provenance can arise only from the core ordinary first-use extension and -is never inherited by a replacement. - -## Privileged transitions - -### Provision binding - -```text -ProvisionBinding(i, k): - require domain enrollment policy = provisioned - require TargetEligible(i, k, false) - require Q_D(i) is absent - require fresh target-key evidence - create Binding(i, k, new_version, provisioned) -``` - -The transition creates no authorization lease. Later use requires a current -assertion, fresh Nostr proof, and ordinary final admission. Ordinary -request-time authorization under `provisioned` policy MUST NOT create a binding -(`FI-LC-PROVISION`). - -### Disable identity - -```text -DisableIdentity(i): - add i to X_D - if Binding(i, k, old_version) exists: - remove Binding(i, k, old_version) - add (i, k) to the core retired-pair set - set Q_D(i) = (i, k, old_version) -``` - -Applying an authorized disablement repeatedly is idempotent. It MUST NOT erase -or replace existing lineage. If `i` has no active binding, disablement creates -no lineage (`FI-LC-DISABLE`). - -### Re-enable identity - -```text -ReenableIdentity(i, expected_lineage?, k_new): - require i is in X_D - require Q_D(i) is absent when expected_lineage is absent, - otherwise require Q_D(i) = expected_lineage - require TargetEligible(i, k_new, true) - require fresh target-key evidence - remove i from X_D - consume expected_lineage when present - create Binding(i, k_new, new_version, ReplacementProvenance(evidence)) -``` - -Clearing disabled state and creating the target binding are inseparable. There -is no clear-only transition: it would permit a later ordinary enrollment to -capture the identity. An operator that intends to provision later leaves the -identity disabled until the target and fresh proof are available -(`FI-LC-REENABLE`). - -### Set administrative expiry - -```text -SetAdministrativeExpiry(i, k, old_version, binding_not_after?): - require exact current Binding(i, k, old_version) - require separate privileged expiry authority - replace it with Binding(i, k, new_version, - same_provenance, binding_not_after?) -``` - -This transition changes neither side of the pair nor its provenance. Setting, -replacing, or clearing the bound advances the binding version. At equality the -binding is ineligible but remains durable and occupies both sides of the core -partial bijection. This transition is the only expiry authority: no other -transition in this profile or in core sets, replaces, or clears the bound, and -core rotation carries it onto the replacement binding unchanged. Only this or -another applicable privileged transition can restore access; ordinary -authorization cannot renew the bound (`FI-LC-ADMIN-EXPIRY`). - -## One-shot lineage and concurrency - -Consumption of `Q_D` and creation of its replacement binding MUST be one -compare-and-commit operation over the exact pending lineage. Of two concurrent -re-enablings presenting the same lineage, at most one can commit. The loser -observes changed state and denies without creating a binding, consuming another -lineage, or changing history (`FI-LC-QD-ONCE`). - -A lifecycle transition racing ordinary final admission is ordered by the same -authoritative state transaction or dependency check. If the lifecycle commit -wins, the ordinary operation denies; if final admission wins first, the -lifecycle transition still invalidates subsequent lease use. No ordering -permits authority from a disabled identity, consumed lineage, or expired -binding after the corresponding state change is observed. - -## Behavioral oracles - -Each oracle is normative. A conforming implementation produces the stated -result at final admission and retains no partial authoritative mutation from a -denied case. - -| ID | Setup and required result | -|---|---| -| `FI-LC-CLAIM` | For a fixed profile set, compare complete discovery bytes across attested-key, TOFU, and provisioned configurations and across lifecycle states: they are identical. If one protected ingress omits lifecycle gates or uses a different lifecycle lineage, the domain cannot advertise the profile and the uncovered ingress fails closed. If any claimed profile defines a binding-creating transition that declares neither grant continuation nor grant establishment, the domain cannot advertise that profile. | -| `FI-LC-ORDINARY-GATES` | Fresh assertion and proof for a disabled identity, an identity with pending lineage, and a binding at administrative-expiry equality each deny without changing lifecycle state. | -| `FI-LC-AUTHORITY` | An ordinary assertion plus valid Nostr proof, but no transition-specific authority, cannot perform any transition; mutation of any authority-bound field denies. | -| `FI-LC-ATOMIC` | Inject failure at each transition write boundary; no binding, tombstone, disabled fact, lineage, history entry, or dependency version is partially committed. | -| `FI-LC-TARGET-PROOF` | Missing, stale, wrong-key, wrong-request, or mismatched required attestation for a new target denies without mutation. | -| `FI-LC-PROVISION` | Ordinary first use in provisioned mode denies; authorized provisioning creates one binding and no lease; later current ordinary admission may use it. | -| `FI-LC-DISABLE` | Disabling an active identity atomically disables it, retires its exact pair, records exact lineage, and closes subsequent lease use; replay is idempotent and preserves lineage. | -| `FI-LC-REENABLE` | Re-enablement creates an eligible proven binding in the same commit that clears disabled state; absent or wrong expected lineage and a clear-only attempt deny. | -| `FI-LC-ADMIN-EXPIRY` | Before the bound the binding may authorize; at equality it denies while still occupying the relation, so a target eligibility test for either side of that pair fails. Rotating the expired binding to a new key carries the bound: the replacement denies at the same instant. No non-expiry transition clears it. Only an authorized version-checked update by the expiry authority changes the bound. Conversely, retirement, revocation, or re-enablement of the bound pair followed by an authorized or ordinary new grant produces an unbounded binding, which is the required result and not an escape. | -| `FI-LC-QD-ONCE` | Two concurrent re-enablings consume the same `Q_D` lineage; exactly one commits and the loser leaves every authoritative store unchanged. | -| `FI-LC-RACE` | Race each transition against prepared ordinary admission and lease use; no operation authorizes after observing the advanced lifecycle or binding dependency. | - -## Security considerations - -Privileged authority compromise can provision or replace enterprise bindings; -deployments should apply controls proportionate to that authority. This profile -makes the authority request-bound and transitions atomic, but does not define -approval UX or key custody. - -Disabled identities, retired pairs, revoked keys, and pending lineage serve -different purposes. Re-enablement removes only the exact disabled fact and -optional exact lineage named by its transition. No transition in this profile -removes a core revoked-key or retired-pair fact. - -Administrative expiry is local policy, not upstream revocation freshness. It -cannot extend an assertion, status witness, Nostr proof, or lease deadline. diff --git a/docs/nips/NIP-FI-MODEL.md b/docs/nips/NIP-FI-MODEL.md deleted file mode 100644 index 821477755cc..00000000000 --- a/docs/nips/NIP-FI-MODEL.md +++ /dev/null @@ -1,96 +0,0 @@ -NIP-FI-MODEL -============ - -Composed authorization model (non-normative) --------------------------------------------- - -This companion is explanatory. It defines no requirement, invariant, wire -value, denial mapping, or conformance claim. Normative requirements live in -[NIP-FI](NIP-FI.md) and the claimed profile documents. In particular, -`FI-INV-01` through `FI-INV-16` are defined only by NIP-FI core. - -## State sketch - -One useful implementation model keeps these authoritative relations per domain: - -```text -B_D : active identity-to-key relation -T_D : retired identity/key pairs -Y_D : revoked keys -H_D : immutable lifecycle history -V_D : binding and lifecycle versions -``` - -NIP-FI-LIFECYCLE adds disabled identities and pending replacement lineage. -NIP-FI-DELEG adds relationship state but no delegate binding. NIP-FI-EDGE adds -transport-provenance and replay witnesses. Implementations may use different -storage as long as their observable behavior satisfies the owning normative -documents. - -## Composed direct decision - -The core decision can be read as this equation: - -```text -validated issuer-qualified identity -+ fresh request/connection-bound Nostr proof -+ current durable partial-bijection state -+ current local policy -+ atomic final admission -= authority for exactly the proven key and operation -``` - -Preparation gathers immutable evidence and snapshots every dependency without -mutation. Final admission compares exact context and stable contract identities, -checks all deadlines, revalidates changed snapshots, recomputes from current -binding and policy state, then commits replay claims, optional enrollment, and a -receipt atomically. The special concurrent-enrollment normalization is narrow: -an `enroll(i,k)` proposal may become the same eligible `existing(i,k)` result; -a different winner is not equivalent. - -## Profile composition - -Profiles contribute witnesses, never alternate final authority: - -```text -core witnesses -∪ EDGE provenance/replay witnesses -∪ LIFECYCLE eligibility/lineage witnesses -∪ DELEG owner/relationship witnesses -``` - -The lease deadline is the minimum of every bound in the resulting set. A missing -or unreadable required witness denies. A profile cannot remove a core witness, -extend a core deadline, replace the proven actor, or create a second admission -lineage. - -For direct authorization, the path dependency is the normalized assertion and -its current snapshot/status witnesses. For delegated authorization, it is the -exact eligible owner binding plus relationship evidence; direct assertion fields -are absent. Both paths share context resolution, Nostr-proof validation, local -policy, read-only preparation, and atomic final admission. - -## Lifecycle intuition - -Bindings are durable; leases are ephemeral. Retirement makes one exact pair -permanently ineligible for ordinary recreation. Revocation makes a key -ineligible throughout the domain. Rotation retires the old pair and creates a -new binding version but does not globally revoke the old key. Extended lifecycle -operations may add disabled identity and one-shot pending-lineage state, as -specified by NIP-FI-LIFECYCLE. - -## Privacy intuition - -Private reasons collapse to fixed public bytes. In particular, binding -conflicts, tombstones, lifecycle gates, key mismatch, enrollment requirements, -and local-policy decisions are indistinguishable. Operational diagnostics may -retain bounded private reason codes, but such records are not protocol objects -and never become authorization witnesses. - -## Reading order - -1. NIP-FI for core state, wire behavior, invariants, and direct admission. -2. NIP-FI-EDGE for a trusted-enterprise edge. -3. NIP-FI-LIFECYCLE for provisioning, disablement, and re-enablement. -4. NIP-FI-DELEG for delegated agents. -5. NIP-FI-CONF for claim and evidence rules. diff --git a/docs/nips/NIP-FI.md b/docs/nips/NIP-FI.md index b5b35ca5dd7..845a150a4ee 100644 --- a/docs/nips/NIP-FI.md +++ b/docs/nips/NIP-FI.md @@ -1,14 +1,12 @@ NIP-FI ====== -Federated identity authorization — core ----------------------------------------- +Federated identity authorization — stateless core +--------------------------------------------------- `draft` `optional` `relay` -**Protocol dependencies**: NIP-01 and either NIP-42 or NIP-98. Optional -profiles are defined by NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, and -NIP-FI-CONF. +**Protocol dependencies**: NIP-01, NIP-42. The key words "MUST", "MUST NOT", "REQUIRED", "SHOULD", "SHOULD NOT", and "MAY" in this document are to be interpreted as described in BCP 14 (RFC 2119 @@ -16,485 +14,457 @@ and RFC 8174) when, and only when, they appear in all capitals. ## Abstract -NIP-FI authorizes a Nostr key only when four independent facts agree: a valid -issuer-qualified identity assertion, fresh proof of that Nostr key, current -identity-to-key binding state, and current local policy for the exact operation. -The identity provider never signs Nostr events, and an assertion never replaces -Nostr proof. +NIP-FI authorizes a Nostr key when two independent facts agree: a valid +issuer-qualified identity assertion that names the key, and fresh NIP-42 proof +of possession of that key. No relay-side identity state is required. The +relay verifies the assertion offline against configured per-issuer JWKS +snapshots; every identity decision beyond key verification is the assertion +issuer's responsibility. -Bindings outlive individual assertions. Assertions and authorization leases do -not outlive their evidence. This core defines the portable client-attached -assertion transport, direct enrollment, atomic final admission, bounded -sessions, privacy-preserving denial responses, and the smallest useful binding -lifecycle. Companion profiles add trusted edges, extended lifecycle operations, -and delegation without changing the core admission rule. +This NIP defines the assertion contract, the offline verification procedure, +session lifetime policy, and an authenticated issuer→relay disconnect API. +Enrollment, rotation, revocation decisions, identity↔key registry, one-identity +one-key enforcement, audit, and directory integration are issuer concerns outside +this spec. -This NIP does not define an identity provider, database schema, operator API, -public identity projection, application membership policy, or user interface. +## Terms -## Terms and identifier classes - -- **domain** (`D`): an authorization boundary selected only by authenticated - server routing and configuration. - **identity** (`i`): the exact tuple `(iss, sub)` returned by assertion - validation. Email, display name, employee number, and a bare `sub` are not - identities. -- **target context** (`R_t`): the server-resolved method, authority, path and - query, body semantics, transport, operation, and resource. -- **actor** (`k`): the 32-byte public key returned by Nostr-proof validation. -- **request context** (`R`): `R_t` sealed with `k`. -- **binding**: a durable, versioned association `(D, i, k)` with immutable - provenance `attested-key`, `tofu`, or `provisioned`. -- **retired pair**: a durable denial fact for an exact `(D, i, k)`. -- **revoked key**: a durable denial fact for `(D, k)`. -- **prepared authorization**: immutable, read-only evidence and witnesses for a - possible admission. -- **committed authorization**: authority returned only after final revalidation - and atomic commit. -- **lease**: a cached committed decision for one actor and bounded operation - set. A lease is not a binding. - -Identity and authorization-state comparisons preserve every tuple component. -Equal `sub` values under different `iss` values are distinct identities; equal -`(i, k)` pairs under different domains are distinct bindings, retired pairs, -and authorization state. [FI-TRACE-CROSS-DOMAIN-COLLISION] - -Every identifier is either **interoperability-critical** or -**deployment-local**. Header names, public response bytes, token type values, -and trace identifiers are interoperability-critical and fixed here. -`assertion_policy_id`, `transport_contract_id`, domain IDs, snapshot versions, -binding versions, policy versions, and correlation IDs are deployment-local; -their values are opaque outside a deployment, while their stability and -invalidation behavior are normative. - -## Core security invariants - -These labels are the normative home of the NIP-FI invariants. Companion -profiles may add witnesses and bounds but cannot weaken them. - -1. **`FI-INV-01 — partial bijection.`** Active bindings are one-to-one within a - domain: one identity has at most one active key and one key has at most one - active identity. [FI-TRACE-BINDING-CONFLICT] -2. **`FI-INV-02 — durable binding.`** Assertion expiry removes neither a - binding nor its provenance. Fresh eligible evidence may authorize the same - binding later. [FI-TRACE-ASSERTION-REFRESH] -3. **`FI-INV-03 — tombstone monotonicity.`** Ordinary authorization never - removes a retired-pair or revoked-key fact and never recreates a retired - pair. [FI-TRACE-TOMBSTONE-REPLAY] -4. **`FI-INV-04 — server-owned context.`** Every admitted operation uses one - server-resolved domain, target, resource, operation, and proven actor. - Unauthenticated input cannot replace them. [FI-TRACE-DOMAIN-SPOOF] -5. **`FI-INV-05 — independent evidence.`** Direct authorization requires a - current assertion and fresh Nostr proof. If the assertion names a key, it - equals the proven actor. [FI-TRACE-ASSERTION-KEY-MISMATCH] -6. **`FI-INV-06 — stable assertion policy.`** Assertion-policy identity changes - when accepted assertion semantics change, but not when only authenticated - key or status snapshot contents rotate. [FI-TRACE-VERIFIER-PARITY] -7. **`FI-INV-07 — current-snapshot verification.`** Evidence cannot survive - removal of the key or policy snapshot that authenticated it; a changed - snapshot requires revalidation. [FI-TRACE-JWKS-REMOVE] -8. **`FI-INV-08 — read-only preparation.`** Preparation creates no binding, - tombstone, replay claim, receipt, lease, publication, last-seen value, audit - authority, or application mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] -9. **`FI-INV-09 — atomic final admission.`** Enrollment, replay claims, - receipts, and required authorization evidence commit only after complete - final revalidation, all or none. [FI-TRACE-PREPARED-STALE] -10. **`FI-INV-10 — explicit lifecycle authority.`** Retirement, revocation, - rotation, and profile-defined lifecycle changes occur only through their - separately authorized transition. [FI-TRACE-LIFECYCLE-AUTHORITY] -11. **`FI-INV-11 — evidence-bounded leases.`** A lease ends no later than every - evidence, snapshot, proof, binding, local-policy, and implementation bound - on which it depends. [FI-TRACE-LEASE-BOUND] -12. **`FI-INV-12 — current-owner delegation.`** When NIP-FI-DELEG is claimed, - delegation requires the exact current eligible owner binding, fresh - delegate proof, capability intersection, and a positive finite deadline. - [FI-DELEG-OWNER-CURRENT] -13. **`FI-INV-13 — privacy-safe denial.`** Public rejection is many-to-one and - reveals no identity, key, claim, binding, tombstone, enrollment mode, key - identifier, or private policy fact. [FI-TRACE-DENIAL-ORACLE] -14. **`FI-INV-14 — fail closed.`** Unreadable, ambiguous, stale beyond policy, - or inconsistent evidence or authoritative state cannot produce authority. - [FI-TRACE-DEPENDENCY-FAIL-CLOSED] -15. **`FI-INV-15 — uniform authority.`** Every protected ingress in a domain - uses the same current domain policy and final-admission authority. An - uncovered or competing path is unavailable. [FI-TRACE-AUTHORITY-UNIFORM] -16. **`FI-INV-16 — canonical verifier.`** Assertion transports feed one closed, - provider-neutral normalized-result contract and cannot fork final - admission. [FI-TRACE-VERIFIER-PARITY] + validation. Email, display name, opaque user ID, and a bare `sub` are not + identities. Equal `sub` values under different `iss` values are distinct + identities. [FI-TRACE-CROSS-DOMAIN-COLLISION] +- **actor** (`k`): the 32-byte public key returned by NIP-42 proof validation. +- **assertion**: a compact JWS minted by the assertion issuer, binding `i` + to `k`. +- **assertion issuer**: the deployment-specific identity authority (e.g. an + OIDC identity provider integration) that authenticates users and mints + assertions. The relay trusts only the issuer's assertion; it does not + contact the IdP directly. -## Client-attached transport +## Assertion contract + +The assertion is a compact JWS carrying the following claims. + +### Required claims + +| Claim | Type | Semantics | +|---|---|---| +| `iss` | string | Exact issuer URI. The relay selects an issuer policy by exact match; no normalization is applied. | +| `sub` | string | Opaque, stable, non-reassignable subject identifier for the account lifetime. Never an email address or display name. | +| `nostr_pubkey` | string | Lowercase hexadecimal encoding of exactly one 32-byte Nostr public key. Other encodings deny. | +| `aud` | string or array | Audience. MUST be present. The relay requires an exact match to the configured audience value for this issuer. | +| `iat` | NumericDate | Issuance time. | +| `exp` | NumericDate | Expiry time. MUST be finite. The deployment MUST configure a positive finite maximum TTL; the relay enforces both the token `exp` and the configured `maximum_assertion_age`. | + +### Optional claims + +| Claim | Type | Semantics | +|---|---|---| +| `nbf` | NumericDate | Not-before time. When present, the relay enforces `nbf <= now + skew`. | + +### Token type + +Policy selects exactly one token class before parsing claims: + +- **`nip-fi+jwt`**: a dedicated assertion whose protected `typ` is exactly + `nip-fi+jwt`. +- **`at+jwt` access token**: a resource access token whose protected `typ` is + exactly `at+jwt`. When this class is selected: + - The assertion MUST contain a non-empty `client_id` claim. + - The issuer policy MUST name exactly one authenticated marker claim and two + non-empty, disjoint value sets: one for resource-owner subjects and one for + client-subject tokens. A token whose marker value matches neither set, both + sets, or whose marker claim is absent is ambiguous and denies. + - When client-subject tokens are admitted, the issuer policy MUST record the + non-collision posture: the issuer MUST guarantee that resource-owner and + client-subject `(iss, sub)` coordinates are disjoint. + - Absent, unknown, or ambiguous classification always denies; no fallback to + the other class is attempted. + +OIDC ID tokens always deny, even when `iss`, `aud`, and `sub` match. A +generic or absent `typ` has no accepted class. Failure under one class never +triggers validation under another. [FI-TRACE-TOKEN-CLASS] + +### Time bounds + +**Required claims:** `iat` and `exp` MUST be present; absence denies. + +**Policy knobs:** the relay enforces the following rules. `maximum_assertion_age` +is a required positive finite configuration; a missing or non-positive +configuration denies. `skew` is a non-negative finite maximum with default `0`; +it narrows acceptable bounds and cannot be omitted to mean "unchecked". + +- `now < exp` — equality at expiry is expired +- `iat <= now + skew` — issuance is not in the future beyond allowable skew +- `now < iat + maximum_assertion_age` — caps total assertion age independent of `exp` +- `nbf <= now + skew` — when `nbf` is present (optional claim; absence is not an error) + +[FI-TRACE-ASSERTION-VALIDATION] -Server configuration selects `client-attached` before protected traffic is -accepted. Request fields cannot select, negotiate, or downgrade transport. -Failure never falls back to another transport. [FI-TRACE-TRANSPORT-CLOSED] +### Assertion–key binding -The client sends exactly one field on the request or WebSocket upgrade: +`nostr_pubkey` MUST name the exact key the client proves via NIP-42. The relay +denies any token whose `nostr_pubkey` does not match the NIP-42 `pubkey`. +[FI-TRACE-ASSERTION-KEY-MISMATCH] + +This is the entire identity-to-key binding. There is no relay-side binding +ledger; the assertion is the binding claim, and it is the assertion issuer's +responsibility to ensure the assertion names the correct key. + +### Policy identity + +```text +AssertionPolicyId = H(canonical assertion-policy contract) +TransportContractId = H(canonical transport contract) +``` + +`AssertionPolicyId` covers the canonical issuer, audience, token class, +allowed algorithms, key-source contract, identity/key/claim mapping, time and +size rules, and compiled verifier behavior. JWKS key rotation changes the +snapshot, not the policy ID. `TransportContractId` covers the client-attached +field, parsing, attachment, and no-fallback semantics. + +## Client-attached transport + +The client sends exactly one field on the WebSocket upgrade request: ```text Nostr-Federated-Identity: Bearer ``` -`Authorization` remains reserved for NIP-98. Assertion and provenance fields -from any other profile are absent. Missing, repeated, comma-combined, empty, -malformed, non-Bearer, or mixed-profile fields deny. Assertions never appear in -URLs, query parameters, Nostr events, tags, filters, application history, or -public identity projections. [FI-TRACE-TRANSPORT-CLOSED] +`Authorization` remains reserved for NIP-98. Missing, repeated, +comma-combined, empty, malformed, non-Bearer, or mixed-profile fields deny. +Assertions MUST NOT appear in URLs, query parameters, Nostr events, tags, or +filters. [FI-TRACE-TRANSPORT-CLOSED] + +Server configuration selects `client-attached` before any protected traffic is +accepted. Request fields cannot select, negotiate, or downgrade the transport. +Failure never falls back to another transport. + +## Verification + +The relay verifies assertions **offline** against configured per-issuer JWKS +snapshots. No IdP contact occurs at admission time. + +### Multi-issuer registry + +The relay maintains one [`IssuerRegistry`](../../crates/buzz-auth/src/nip_fi/config.rs): +a map from exact `iss` strings to issuer policies. The `iss` carried in the +signed token selects exactly one policy; unknown issuers deny. A +single-issuer deployment is a registry of length one. [FI-TRACE-CROSS-DOMAIN-COLLISION] + +The existing `FederatedAssertionVerifier` and `ProductionJwksSource` +(merged in PR 3 / `70895b355`) implement the verification procedure described +here. The `require_attested_key` flag in `IssuerPolicy` is the per-issuer +enforcement primitive for the unconditional `nostr_pubkey` requirement in this +section; conformance to NIP-FI v2 requires startup validation that forces this +flag true for every configured issuer. That integration is a follow-on code +change outside this PR. + +### JWKS snapshot -The core transport contract has deployment-local identity -`transport_contract_id`. It deterministically identifies the exact field, -parsing, request-attachment, no-fallback, and context-preservation semantics. -Changing any of those semantics changes the ID; changing request data does not. -[FI-TRACE-CONTRACT-IDENTITIES] +Each issuer policy configures: -## Assertion validation +- `jwks_uri`: HTTPS URI selecting the authenticated key source. SSRF-protected + at both URI validation and DNS-resolution time; no credentials, fragments, + or private-IP endpoints accepted. +- `refresh_interval_seconds`: positive, ≤ 1 year, strictly less than + `key_snapshot_hard_deadline_seconds`. +- `key_snapshot_hard_deadline_seconds`: the outer time bound after which no + assertion verified under this snapshot can authorize. -A configured assertion policy accepts exactly one bounded compact JWS and -returns this closed result: +The snapshot is re-fetched periodically. A key added to the JWKS is accepted +after the next fetch; a key removed from the JWKS causes any assertion verified +under that key to deny on next revalidation. [FI-TRACE-JWKS-ADD] +[FI-TRACE-JWKS-REMOVE] + +The snapshot is authenticated: no external consumer can relabel one issuer's +JWKS as another's. The maximum number of keys per snapshot is bounded before +any attacker-controlled `kid` lookup. + +### Verification procedure ```text -VerifiedAssertion = ( - identity = (iss, sub), - asserted_key?, - claims_or_capabilities, - authority_deadlines, // non-empty - assertion_policy_id, - transport_contract_id, - revalidation_dependencies -) +VerifyAssertion(token, D, R_t): + // 1. Select issuer policy + (header, claims) := BoundedJwsDecode(token) or DENY(evidence_rejected) + policy := IssuerRegistry[claims.iss] or DENY(evidence_rejected) + + // 2. Validate token class, typ, and algorithm + ValidateTokenClass(policy, header) or DENY(evidence_rejected) + AssertAsymmetricAlgorithm(header.alg) or DENY(evidence_rejected) + + // 3. Validate signature against current authenticated JWKS + snapshot := policy.key_source.get_snapshot() or DENY(authorization_unavailable) + key := snapshot.find(header.kid) or DENY(evidence_rejected) + VerifySignature(token, key) or DENY(evidence_rejected) + + // 4. Validate claims + AssertExactIss(claims.iss, policy.iss) or DENY(evidence_rejected) + AssertAudienceMatch(claims.aud, policy.aud) or DENY(evidence_rejected) + AssertTimeBounds(claims, policy) or DENY(evidence_rejected) // [FI-TRACE-ASSERTION-VALIDATION] + k_claimed := ParseHexKey(claims.nostr_pubkey) or DENY(evidence_rejected) + + return VerifiedAssertion(identity=(claims.iss, claims.sub), asserted_key=k_claimed, + authority_deadlines=ComputeDeadlines(claims, snapshot)) ``` -The verifier rejects ambiguous protected-header or claim members, unknown -critical headers, `alg=none`, symmetric algorithms, algorithm/key mismatch, -incompatible JWK usage, ambiguous key selection, and signatures not valid -under exactly one accepted asymmetric key. It bounds the assertion, headers, -claims, subject, key identifiers, and authenticated key set before lookup or -logging. [FI-TRACE-ASSERTION-VALIDATION] - -The exact `iss` selects an authenticated policy and key source; `iss` and at -least one `aud` value exactly match configured values. `sub` is a non-empty -bounded string. Each policy configures a non-negative finite `skew`, a positive -finite `maximum_assertion_age`, and, for `current-status`, a positive finite -`maximum_status_age`; a missing value denies. `exp` and `iat` are finite -NumericDate values satisfying `now < exp`, `iat <= now + skew`, and -`now < iat + maximum_assertion_age`. Optional `nbf` satisfies -`nbf <= now + skew`. Arithmetic is overflow-safe and equality at an expiry is -expired. [FI-TRACE-ASSERTION-VALIDATION] - -The Nostr-key claim is named `nostr_pubkey`. When present it MUST be a -lowercase hexadecimal encoding of exactly one 32-byte Nostr public key; other -encodings and aliases deny. In `attested-key` enrollment policy and wherever -current matching issuer attestation is required, this exact claim MUST be -present and equal the proven actor; authorization claims or capabilities use a -closed bounded input set and deterministic canonical encoding. Unchecked claims -never enter the result. [FI-TRACE-VERIFIER-PARITY] - -### Token class +The verifier is **fail-closed**: any unreadable, missing, ambiguous, or +expired input denies. A missing JWKS snapshot denies with +`authorization_unavailable`; all other failures deny with `evidence_rejected`. +[FI-TRACE-DEPENDENCY-FAIL-CLOSED] -Policy selects exactly one token class before parsing claims: +### Admission at connection + +On WebSocket upgrade: + +1. Extract `Nostr-Federated-Identity` header; missing or malformed → deny + `missing_evidence` or `evidence_rejected`. +2. Call `VerifyAssertion`; any error → deny per the rejection table. +3. Complete NIP-42 handshake; validate AUTH event, extract `k`. +4. Assert `verified.asserted_key == k`; mismatch → deny `authorization_denied`. + [FI-TRACE-ASSERTION-KEY-MISMATCH] +5. Admit the connection. The session's authority deadline is the minimum of all + `authority_deadlines`; see Session policy. + +## Session policy + +### Maximum connection lifetime + +Every NIP-FI deployment MUST configure a positive finite +`max_connection_lifetime_seconds`. This is a **required deployment knob**; +there is no default that permits an indefinite session. Operators MUST select +a value; infosec policy governs the specific bound. -- **`at+jwt` access token**: a Buzz-resource access token whose protected - `typ` is exactly `at+jwt` and whose `aud` contains the configured Buzz - resource audience. This class selects tokens carrying the RFC 9068 `at+jwt` - type but validates them under this document's claim contract; it does not - implement the full RFC 9068 validation profile, and the long-form media type - `application/at+jwt` is not accepted; -- **dedicated Buzz assertion**: a separately minted assertion whose protected - `typ` is exactly `nip-fi+jwt`. - -OIDC ID Tokens always deny, even when `iss`, `aud`, and `sub` match. A generic -or absent type has no accepted class: claim presence alone cannot prove a token -disjoint from an OIDC ID token, since an issuer can mint an ID token carrying -`client_id`, and the only authenticated discriminator is `typ`. Failure under -one class never triggers validation under another. An `at+jwt` access token MUST -contain one non-empty bounded `client_id`. Issuer policy MUST distinguish a -resource-owner token from a token -whose subject represents the OAuth client, including a client-credentials token, -using authenticated claim semantics and mutually exclusive validation rules. A -token that admits both interpretations denies. If client-subject tokens are -accepted, the issuer MUST guarantee that their `(iss, sub)` coordinates cannot -collide with resource-owner coordinates; otherwise that token class is -ineligible. Token class and every class-specific validation rule are inputs to -`assertion_policy_id`. [FI-TRACE-TOKEN-CLASS] - -### Policy identity and snapshots - -Core has exactly two semantic contract identities: +A connected session MUST be terminated no later than `connection_time + max_connection_lifetime_seconds`, +regardless of assertion expiry. + +The effective session deadline is: + +``` +session_deadline = min( + connection_time + max_connection_lifetime_seconds, + min(authority_deadlines), // from VerifiedAssertion + key_snapshot_hard_deadline // from the issuer policy +) +``` + +Equality at any deadline is expired. Arithmetic is overflow-safe. +[FI-TRACE-LEASE-BOUND] + +### Re-authentication + +There is **no in-band session renewal**. When a session expires, the relay +closes the WebSocket. The client must open a new connection with a fresh +assertion on the upgrade request and complete a fresh NIP-42 proof. A silent +re-mint riding an existing issuer/IdP session is an issuer implementation +detail; the relay never sees anything other than a new upgrade request. + +### Reconnect after expiry + +A client whose session expired due to normal TTL expiry may reconnect +immediately provided the issuer can supply a fresh assertion. Session expiry +does not imply key revocation or identity loss; that is the issuer's domain. + +## Admin disconnect API + +The assertion issuer can terminate live relay sessions for a specific public key via an +authenticated `disconnect` call. + +### Semantics (session-only) + +A disconnect call causes the relay to close all live WebSocket connections +whose proven `k` equals the target pubkey. This is a **session-only** +operation: it closes existing connections but does not prevent the key from +reconnecting. After disconnection, a client holding a still-valid JWT can +reconnect immediately. + +> **Non-normative note — open product question (session-only vs deny-until-TTL):** +> +> The session-only model means a revoked user retains access until their +> assertion's effective authority expires. After a successful disconnect call +> (all matching sessions closed synchronously), there is no surviving +> old-session window. If the issuer also stops issuing new assertions at +> that point, cumulative residual access is bounded by: +> +> ``` +> max(0, min(exp, iat + maximum_assertion_age) - now) +> ``` +> +> `max_connection_lifetime_seconds` only partitions that interval into +> individual sessions; it does not shorten the total window. A snapshot +> refresh failure, hard-deadline expiry without key replacement, or signing-key +> removal can terminate access earlier, but these are not reliable protocol-level +> bounds: the JWKS snapshot deadline renews on each refresh even when content is +> unchanged, so it does not cap cumulative access. If the issuer +> continues issuing new assertions after the disconnect call, cumulative +> access extends indefinitely — the session-only protocol places no +> protocol-level bound on that case. +> +> If the disconnect call is asynchronous or best-effort, the spec would need +> to define a completion-bound contract; the current normative text assumes +> synchronous close. +> +> The alternative is a **deny-until-TTL** model: the relay holds a +> memory-resident deny-list entry for the pubkey keyed to the issuer's stated +> TTL, and any reconnect attempt for that key is denied `authorization_denied` +> until the entry expires. This eliminates the reconnect window at the cost of +> relay in-memory state and a TTL-propagation contract between issuer and relay. +> +> This document intentionally leaves that decision unresolved. The current +> normative text describes session-only. If deny-until-TTL is chosen, Section 6 +> must be revised to add: the TTL parameter on the disconnect call, the +> deny-list data structure (keyed by pubkey, value = absolute expiry), the +> deny-list check at admission (step 4), and the expiry/eviction rule. + +### Transport + +The disconnect endpoint is an authenticated issuer→relay API, not a public +Nostr protocol. + +### Command JWT + +Authentication uses a short-lived signed command JWT with a dedicated token +type. The relay verifies it with a **dedicated command verifier** that reuses +the same `IssuerRegistry`, bounded JWS parsing, issuer-bound JWKS snapshots, +signature verification, audience, and time-bound primitives as assertion +verification, but operates over a distinct token type and produces a closed +command result. The `VerifyAssertion` primitive is not used here. + +The command JWT protected header MUST carry `"typ": "nip-fi-command+jwt"`. +Any other `typ` value denies before claim parsing. + +The command JWT MUST carry the following claims: + +| Claim | Requirement | +|---|---| +| `iss` | Exact issuer URI matching an authorized issuer in the registry. | +| `sub` | Issuer principal identifier. The relay checks this is an authorized issuer principal. | +| `aud` | Audience matching the relay's configured audience value for this issuer. | +| `iat` | Issuance time. MUST satisfy `iat <= now + skew`. | +| `exp` | Expiry time. MUST be finite; relay enforces `now < exp`. | +| `jti` | Unique, non-guessable identifier for this command. Used for replay prevention; see below. | +| `method` | Exactly `"POST"` (uppercase literal). Binds the command to the HTTP method. | +| `path` | Exactly `"/api/nip-fi/disconnect"` (literal string). Binds the command to the endpoint. | +| `cmd` | Exactly `"disconnect"` (literal string). Operation selector. | +| `target_pubkey` | Lowercase hexadecimal encoding of the target 32-byte Nostr public key — the same encoding required for the assertion `nostr_pubkey` claim. | + +The `maximum_command_age` policy knob is a required positive finite +configuration per authorized issuer, with a normative upper bound of +60 seconds. The relay enforces `0 < maximum_command_age <= 60` and +`now < iat + maximum_command_age` in addition to `now < exp`. A missing, +non-positive, or out-of-range configuration denies. + +The `VerifyCommandJwt` procedure: ```text -assertion_policy_id = H(canonical assertion-policy contract) -transport_contract_id = H(canonical transport contract) +VerifyCommandJwt(token, request_method, request_path, request_body_pubkey): + // 1. Bounded decode and type check + (header, claims) := BoundedJwsDecode(token) or DENY(evidence_rejected) + assert header.typ == "nip-fi-command+jwt" or DENY(evidence_rejected) + + // 2. Select issuer policy; verify signature + policy := IssuerRegistry[claims.iss] or DENY(evidence_rejected) + AssertAsymmetricAlgorithm(header.alg) or DENY(evidence_rejected) + snapshot := policy.key_source.get_snapshot() or DENY(authorization_unavailable) + key := snapshot.find(header.kid) or DENY(evidence_rejected) + VerifySignature(token, key) or DENY(evidence_rejected) + + // 3. Validate claims (pure verification — no side effects) + AssertExactIss(claims.iss, policy.iss) or DENY(evidence_rejected) + AssertAudienceMatch(claims.aud, policy.aud) or DENY(evidence_rejected) + AssertCommandTimeBounds(claims, policy) or DENY(evidence_rejected) + // enforces: now < exp, iat <= now + skew, now < iat + maximum_command_age + assert claims.method == request_method or DENY(evidence_rejected) + assert claims.path == request_path or DENY(evidence_rejected) + assert claims.cmd == "disconnect" or DENY(evidence_rejected) + target_k := ParseHexKey(claims.target_pubkey) or DENY(evidence_rejected) + + // 4. Principal authorization (pure check — no side effects) + AssertAuthorizedIssuerPrincipal(claims.iss, claims.sub) or DENY(authorization_denied) + + // 5. Signed-target / request-body agreement (pure check — no side effects) + assert target_k == request_body_pubkey or DENY(authorization_denied) + + // 6. Atomically reserve jti — final admission step, immediately before side effects. + // The reservation is keyed by (iss, jti) and held until the command's + // effective expiry: min(exp, iat + maximum_command_age). This step MUST + // be the last mutation before disconnect side effects; performing it before + // steps 4 or 5 would burn the signed command identity on failed-authorization + // or mismatched-body requests, violating the fail-closed contract. + effective_expiry := min(claims.exp, claims.iat + policy.maximum_command_age) + AtomicReserveJti(claims.iss, claims.jti, effective_expiry) or DENY(authorization_denied) + + return CommandResult(target_pubkey=target_k, caller=(claims.iss, claims.sub)) ``` -Each uses one implementation-defined but deterministic, versioned encoding and -collision-resistant hash within a deployment. `assertion_policy_id` covers the -canonical issuer, audience, token class, allowed algorithms, authenticated -key/status-source contracts, identity/key/claim mapping, time and size rules, -normalization, freshness class, and compiled verifier behavior. The verifier -fingerprint is an input, not a third identity. `transport_contract_id` covers -the client-attached field, parsing, attachment, context preservation, and -no-fallback semantics; a companion transport may define its own canonical -contract under that same identity slot. A semantic change changes exactly its -owning ID. [FI-TRACE-CONTRACT-IDENTITIES] - -Mutable contents and deployment state are not contract identities. They remain -in `revalidation_dependencies`: authenticated assertion-snapshot version, -verification-key identity, key-snapshot hard deadline, optional status -source/version/deadline, binding/lifecycle/local-policy/resource versions, -proof and replay witnesses, and a confidential handle to the exact compact JWS. -Adding, removing, or replacing an accepted key changes the snapshot version, -not `assertion_policy_id`. Changed dependencies require revalidation under -current state; a retained key may continue, while an absent key denies. -Unknown-key refresh is bounded and coalesced and has no attacker-triggered -stale-key fallback. [FI-TRACE-JWKS-ADD] [FI-TRACE-JWKS-REMOVE] - -The base contract compares the current authenticated snapshot and makes no -anti-rollback promise. A deployment claiming rollback prevention records a -separately authenticated monotonic floor and tests it. [deployment artifact: -assertion-policy review] - -### Freshness class - -Each policy declares exactly one server-owned freshness class, included in -`assertion_policy_id`: - -- **`offline-jwt`** validates the JWT and authenticated key snapshot only. - `upstream_authority_deadline` is the minimum of `exp`, - `iat + maximum_assertion_age`, and the key-snapshot hard deadline. Token age - bounds assertions minted before revocation; it cannot bound an issuer that - continues minting accepted assertions afterward. Enabling this class therefore - requires deployment evidence that revocation stops new accepted issuance, and - discovery reports the unconditional residual bound as unknown (`null`). It - MUST NOT advertise a finite unconditional residual bound. [deployment - artifact: issuer revocation review] -- **`current-status`** additionally requires an authenticated witness - `(iss, sub, token_or_session_id?, active=true, observed_at, valid_until, - status_version, authenticated_source_id)`. Issuer, subject, and optional - session identifier exactly match the assertion. Ambiguous, unauthenticated, - inactive, or expired status denies. `valid_until` is finite and no later than - `observed_at + maximum_status_age`. The upstream deadline is the minimum of - the offline assertion deadlines and `valid_until`. Source outage cannot mint - or extend a witness; an already verified witness remains usable only until - its existing `valid_until`. [FI-TRACE-CURRENT-STATUS-STALE] - -A current-status deployment advertises a tested positive -`maximum_residual_upstream_revocation_seconds`. Prepared evidence and leases -close within that value after upstream revocation, including a revocation racing -final admission. Poll/cache age, event-delivery and processing delay, and -enforcement delay all fit within the advertised value. A push implementation -may close authority sooner but cannot claim a value below its tested worst case. -[FI-TRACE-CURRENT-STATUS-REVOKED] - -An external capability projection whose removal is required to close authority -within a declared revocation bound MUST enter authoritative local-policy state, -not `claims_or_capabilities` from the assertion. That state is reread during -preparation, final admission, and protected lease use. A deployment that carries -such a projection only in assertions cannot claim a revocation bound for its -changes. [FI-TRACE-CAPABILITY-REVOCATION] - -Before enabling an issuer, the operator records authoritative evidence that -`sub` is stable for the account lifetime, never reassigned, and not intentionally -derived from mutable profile data. An issuer that cannot provide this property -is ineligible. [deployment artifact: issuer subject-stability review] - -## Nostr proof and body semantics - -The actor is always returned by fresh Nostr-proof validation, never by an -assertion or unsigned field. NIP-42 binds its AUTH event to the current -challenge, relay URL, connection, and freshness window. NIP-98 binds its event -to the exact server-resolved URL, method, and freshness window. All evidence -agrees with the same `D` and `R_t`. [FI-TRACE-DOMAIN-SPOOF] - -Each protected HTTP operation declares in server policy whether its body is -authorization-relevant; clients cannot select the declaration. - -For a relevant body, the NIP-98 event contains exactly one `payload` tag equal -to lowercase hexadecimal SHA-256 of the **body bytes**: the complete content -after transfer decoding and before any content decoding. Absence, duplication, -mismatch, validation of only a prefix, or substitution of the body bytes after -validation denies. For an irrelevant body, no -authorization decision, target, capability, or effect selector derives from a -body field not bound by NIP-98. A `payload` tag present on an operation whose -body is declared authorization-irrelevant is validated identically against the -body bytes; duplication or mismatch denies. [FI-TRACE-BODY-BINDING] - -Every operation has finite body and spool bounds. A known oversized body is -rejected before hashing; a stream is rejected at octet `limit + 1`; admission -waits for EOF. Before EOF there is no application effect, replay mutation, -receipt, or partial digest authority. Quota failure cleans up staged bytes and -denies. [FI-TRACE-BODY-BOUNDS] - -## Direct preparation - -The following is normative pseudocode; every read is from authoritative state. +Any failure at any step is fail-closed: no side effects occur and the relay +returns the appropriate error. + +This verifier and the disconnect API endpoint are follow-on code changes +outside this PR, in the same way that the `require_attested_key` enforcement +integration is. + +### Request ```text -PrepareDirect(request, assertion, proof): - (D, R_t, operation, resource) := ResolveTargetContext(request) or DENY - e := ValidateClientAttached(assertion, D, R_t) or DENY - k := ValidateNostrProof(proof, D, R_t) or DENY - R := SealActor(R_t, k) - i := e.identity - - if e.asserted_key exists and e.asserted_key != k: DENY(key_mismatch) - atomically read B_D(i), B_D(k), T_D(i,k), Y_D(k), enrollment policy, - local policy, resource, and all dependency versions - if k in Y_D: DENY(key_revoked) - if (i,k) in T_D: DENY(pair_retired) - - if B_D(i) = B_D(k) = binding(i,k): - proposal := existing(binding.version, binding.provenance) - else if B_D(i) exists or B_D(k) exists: - DENY(binding_conflict) - else if enrollment policy = attested-key: - if e.asserted_key != k: DENY(attestation_required) - proposal := enroll(i, k, attested-key) - else if enrollment policy = tofu: - proposal := enroll(i, k, e.asserted_key = k ? attested-key : tofu) - else: - DENY(binding_required) - - EvaluateLocalPolicy(D, R, operation, resource, k, - e.claims_or_capabilities) or DENY - return PreparedAuthorization(evidence, proposal, witnesses, deadlines) +POST /api/nip-fi/disconnect HTTP/1.1 +Nostr-Federated-Identity: Bearer +Content-Type: application/json + +{"pubkey": ""} ``` -TOFU is optional private deployment posture and is not self-advertised. It -accepts that a stolen assertion for a never-enrolled identity can bind an -attacker's proven key; deployments enabling it retain a passing -FI-TRACE-TOFU-THEFT artifact. Binding provenance is immutable. A policy change -affects only future creation. [deployment artifact: TOFU risk review] - -Preparation, including first-use enrollment, is read-only and produces no -authoritative mutation. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] - -## Final admission - -A prepared value is consumed at most once. Final admission first requires an -exact domain, context, operation, resource, actor, and transport match; both -contract IDs unchanged; and every bound live. A changed dependency is reread -and re-evaluated from authoritative evidence. [FI-TRACE-PREPARED-STALE] - -Two verified assertion results are **equivalent** when: - -1. identity-class fields are byte-equal: `iss`, `sub`, asserted-key presence and - value, canonical claims/capabilities, `assertion_policy_id`, and - `transport_contract_id`; -2. each bounds-class deadline — every `authority_deadlines` member, the - key-snapshot hard deadline, and any status deadline — is live now and is no - later than its prepared value; and -3. provenance-class fields — snapshot version, verification-key identity, - status source and version, binding, lifecycle, local-policy, and resource - versions, proof and replay witnesses, the confidential JWS handle, cache - metadata, ordering, and retrieval time — are ignored after successful - current revalidation. - -Every `revalidation_dependencies` member is bounds-class if it is a deadline -and provenance-class otherwise. Any new or unclassified assertion-content field -belongs to the identity class. A fresher assertion cannot silently extend a -prepared decision. [FI-TRACE-PREPARED-STALE] - -Final admission atomically rereads binding, tombstone, revocation, enrollment, -policy, resource, status, replay, receipt, and invalidation witnesses; -recomputes the complete decision; claims applicable proof replay identities; -creates an eligible proposed binding; and appends its request-bound receipt and -required authorization evidence. All commit or none. A concurrent identical -enrollment may recompute as the same `existing` binding; conflicting enrollment -commits at most one winner. [FI-TRACE-CONCURRENT-ENROLLMENT] - -A failed admission rolls back all authority mutation. The application operation -runs only after committed authorization. If it cannot share the transaction, a -request-bound idempotent receipt prevents the same proof from creating a second -effect. [FI-TRACE-FINAL-DENIAL-NO-MUTATION] - -## Base lifecycle - -Retirement, revocation, and rotation require separate privileged authority -bound to the exact domain, transition, identity, old binding version when -present, target key when present, and request. Each atomically rechecks current -state, appends immutable lifecycle history, and invalidates dependent leases -after commit. Every new target key supplies fresh target-bound Nostr proof and -any policy-required current matching issuer attestation. [FI-TRACE-LIFECYCLE-AUTHORITY] - -- **RetirePair** removes one exact active binding and durably retires its pair. -- **RevokeKey** records the key as revoked even if inactive; if active, it also - removes the binding and retires that pair. Repeating the same authorized - revocation is idempotent. -- **Rotate** replaces one exact active binding with one unused, unrevoked, - non-retired target key, retires the old pair, and creates a fresh binding - version. The replacement provenance is `attested-key` when current matching - issuer attestation was validated and `provisioned` otherwise. Rotation does - not globally revoke the old key. Rotation continues one grant onto a new key - rather than establishing a new one: the replacement preserves every - profile-defined administrative bound carried by the binding it replaces, as an - opaque field core neither interprets nor clears. Only the authority that set - such a bound can change it. [FI-TRACE-LIFECYCLE-AUTHORITY] - -Failure or stale state causes no partial mutation. Ordinary authorization cannot -perform or undo these transitions. Extended disablement, re-enablement, -provisioning, and administrative expiry are defined only by NIP-FI-LIFECYCLE. - -## Request and session bounds - -HTTP authority covers one exact request and is never reusable. - -A WebSocket lease is scoped to one actor, domain, operation set, binding -version, normalized result, policy/resource versions, and invalidation -witnesses. Its deadline is the earliest assertion, upstream-authority, -key-snapshot, proof/connection, local-policy, and implementation deadline. -Arithmetic is overflow-safe and equality is expired. [FI-TRACE-LEASE-BOUND] - -Before each protected use, the service checks actor, domain, operation, -resource, deadline, binding version, contract IDs, snapshot/status versions, -policy versions, and invalidation state. Changed dependencies require current -revalidation to an equivalent result; unreadable or ineligible state denies. -A lease for one key never authorizes another key on the same connection. -[FI-TRACE-MULTI-KEY-SESSION] - -Expiry ends the lease, not the binding. Renewal requires a new connection with -a fresh assertion attached to its WebSocket upgrade, fresh Nostr proof, -preparation, and final admission; there is no in-band renewal path. Confidential -assertion revalidation material is destroyed on expiry, close, or invalidation. +The relay calls `VerifyCommandJwt` passing the request method, path, and +body `pubkey` field; any failure denies per the rejection table. On success, +the relay closes all live connections whose proven `k` equals +`CommandResult.target_pubkey`. An unknown or unprovable pubkey is not an +error; the relay responds `200` with `{"disconnected": 0}`. + +### Response + +| Condition | Status | Body | +|---|---|---| +| Authorized; action taken or no-op | `200` | `{"disconnected": }` where `n` is the count of sessions closed | +| Missing or invalid command JWT | `401` / `403` | Per the rejection table | +| Malformed request body | `400` | `bad request\n` | ## Rejection and privacy Public class is a function only of evidence the requester supplied, never of private per-principal server state; `authorization_unavailable` is the sole -exception and reveals only that a required authoritative dependency is -unreadable, never any per-principal fact. Replay status is a function of -committed per-principal server state, not of the supplied evidence alone; -replayed evidence is therefore classed `authorization_denied`, indistinguishable -from any other private-state denial, so that resubmitting captured evidence -reveals nothing about whether the original request committed. Under the -private-posture rule, even `key_mismatch` joins the private-state anonymity set. - -| Private condition | Public class | Nostr prefix and exact text | HTTP response | +exception and reveals only that a required dependency is unreadable. + +| Private condition | Public class | Nostr text | HTTP response | |---|---|---|---| -| assertion/proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; `authentication required\n` | -| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; `evidence rejected\n` | -| replayed evidence; key mismatch; attestation required; binding conflict; retired pair; revoked key; lifecycle gate; binding required/expired; local policy denial | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; `authorization denied\n` | -| required current dependency unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; `authorization unavailable\n` | - -Nostr text is the exact UTF-8 text after an applicable NIP-42/NIP-01 prefix. -A denial decided on a WebSocket upgrade request, before any NIP-42 proof -exists, is the HTTP response in the table, sent instead of `101`; a denial -decided after the connection is established is the Nostr text. For HTTP, the -compared denial contract is closed over the -status, complete body, -and exact values of only the header fields named in the table; header order and -other fields are outside that contract and their values cannot depend on the -private condition. The body is the shown UTF-8 bytes with one LF and no other -bytes. The `Nostr` challenge satisfies RFC 9110 Section 15.5.2. -Responses contain no free text, reason code, request ID, issuer, subject, key, -claim, binding state, enrollment posture, token material, or timing hint. All -private conditions in `authorization_denied` produce byte-identical responses. -[FI-TRACE-DENIAL-ORACLE] - -NIP-FI defines no public identity projection. Public events, tags, filters, -discovery, responses, logs, metrics, and traces contain no raw assertions or -unredacted `iss`, `sub`, email, display name, or private claim. Access-controlled -authoritative stores retain only what enforcement and investigation require. -A separate presentation protocol cannot confer NIP-FI authority. -[FI-TRACE-PRIVACY-NONPUBLIC] +| assertion or proof absent | `missing_evidence` | `auth-required: authentication required` | `401`; `WWW-Authenticate: Nostr`; `Content-Type: text/plain; charset=utf-8`; body `authentication required\n` | +| malformed, invalid, or expired evidence | `evidence_rejected` | `restricted: evidence rejected` | `403`; `Content-Type: text/plain; charset=utf-8`; body `evidence rejected\n` | +| assertion–key mismatch; local policy denial; issuer-initiated disconnect (session-only model) | `authorization_denied` | `restricted: authorization denied` | `403`; `Content-Type: text/plain; charset=utf-8`; body `authorization denied\n` | +| required JWKS snapshot unreadable | `authorization_unavailable` | `restricted: authorization unavailable` | `503`; `Content-Type: text/plain; charset=utf-8`; body `authorization unavailable\n` | + +A denial decided on a WebSocket upgrade is the HTTP response in place of `101`. +A denial decided after the connection is established is the Nostr text. +Responses contain no free text, reason code, issuer, subject, key, claim, or +timing hint. [FI-TRACE-DENIAL-ORACLE] + +NIP-FI defines no public identity projection. Raw assertions, `iss`, `sub`, +email, display name, and private claims MUST NOT appear in public events, tags, +filters, discovery, logs, metrics, or traces. [FI-TRACE-PRIVACY-NONPUBLIC] + +## Out of scope + +The following are issuer and deployment concerns. This spec defines no +normative behavior for them: + +- Identity↔key registry, key ownership records, and the one-identity one-key + constraint: issuer-side. +- Key rotation, re-enrollment after device loss: issuer-side. +- Revocation signaling to the issuer/IdP: issuer-side; the issuer stops + issuing assertions, which closes the relay window within assertion TTL. +- Directory integration and account-offboarding automation: issuer-side. +- Audit logging beyond what the relay operator chooses to retain: issuer-side. +- Delegation: out of scope. +- Companion profiles (NIP-FI-EDGE, NIP-FI-LIFECYCLE, NIP-FI-DELEG, NIP-FI-CONF, NIP-FI-MODEL): removed. ## Discovery @@ -513,119 +483,84 @@ A relay SHOULD advertise core support in NIP-11 as: } ``` -NIP-FI-EDGE owns the optional `edge_transports` member and its exact type, -placement, and value semantics. For `current-status`, the final value is a tested -positive integer. Discovery never states enrollment mode or TOFU posture and -never exposes issuer URLs, audiences, claim names, tenant IDs, or -deployment-local identifiers. For a fixed -set of claimed profiles, the complete public discovery output is byte-identical -for every enrollment policy, including `attested-key`, private `tofu`, and any -companion profile mode: no field, flag, value, omission, ordering, or object shape -may distinguish the configured mode. Profile documents own only non-enrollment -public claims. -[FI-TRACE-DISCOVERY-PRIVATE] - -## Worked example (non-normative) - -A protected HTTP POST under `client-attached` with NIP-98 proof and an -authorization-relevant body. Credentials are elided; the NIP-FI-CONF exit -fixture pins the complete request compared objects. - -```text -POST /media HTTP/1.1 -Host: relay.example -Nostr-Federated-Identity: Bearer eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCIs... -Authorization: Nostr eyJpZCI6IjE1ZTI3ZDc0Li4uIiwicHVia2V5IjoiOTljNzQ4Li4u... -Content-Type: application/octet-stream -Content-Length: 4 - -abcd -``` - -The bearer JWS validates under the configured assertion policy: exact `iss` -and `aud`, token class `at+jwt`, live time claims, and `nostr_pubkey` equal to -the NIP-98 event's `pubkey`. The NIP-98 event binds the server-resolved method -and URL, and its single `payload` tag equals the SHA-256 of the four body -bytes. Admission then follows Direct preparation and Final admission; success -returns the application response, and every failure class returns exactly the -bytes fixed in the Rejection table. On a WebSocket upgrade the same header -attaches to the upgrade request and NIP-42 supplies the proof after connect. - -## Core behavioral oracles +Discovery MUST NOT state issuer URLs, audiences, claim names, tenant IDs, or +deployment-local identifiers. [FI-TRACE-DISCOVERY-PRIVATE] -A core claim covers every applicable oracle below at one implementation and -policy revision. NIP-FI-CONF defines evidence and mutation-adequacy rules. +## Behavioral oracles | ID | Required outcome | |---|---| -| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, mixed, URL, and fallback variants deny. | -| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, ambiguity, and missing-configuration negative denies. | -| `FI-TRACE-TOKEN-CLASS` | An `at+jwt` access token and a dedicated `nip-fi+jwt` assertion pass only their selected class. ID tokens, wrong or generic types, client-only audiences, absent or ambiguous `client_id`, resource-owner/client-subject ambiguity, and every attempted cross-class fallback deny. | -| `FI-TRACE-CONTRACT-IDENTITIES` | Mutate each assertion semantic, transport semantic, and mutable dependency independently: semantic mutations change only their owning contract ID; snapshot/binding/lifecycle/policy/resource/status mutations change neither ID but force current revalidation. | -| `FI-TRACE-VERIFIER-PARITY` | Equal authoritative input and policy produce the same canonical normalized result. | -| `FI-TRACE-JWKS-ADD` | Retained-key rotation revalidates successfully under the changed snapshot version. | -| `FI-TRACE-JWKS-REMOVE` | Evidence and leases under a removed key deny after snapshot change. | -| `FI-TRACE-CURRENT-STATUS-REVOKED` | Revocation, including one racing final admission, closes authority within the advertised tested bound. | -| `FI-TRACE-CURRENT-STATUS-STALE` | Inactive/ambiguous status denies; an issuer, subject, or session-identifier mismatch denies; expiry equality, outage, delayed events, and changed status versions cannot mint or extend a witness. | -| `FI-TRACE-CAPABILITY-REVOCATION` | Removal of a revocation-bounded external capability projection from authoritative local policy closes prepared evidence and lease use within the declared bound; assertion-only projection cannot satisfy this oracle. | -| `FI-TRACE-BODY-BINDING` | Exact complete relevant body passes; absent/duplicate/mutated/partial/substituted payload variants deny without effects; a payload tag on an irrelevant-body operation validates identically and denies on duplication or mismatch. | -| `FI-TRACE-BODY-BOUNDS` | Oversized, over-quota, and pre-EOF variants deny with bounded work, cleanup, and no effects. | -| `FI-TRACE-DOMAIN-SPOOF` | Client routing and forwarded authority cannot replace server-owned context. | -| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch denies with no mutation and the private-state response. | -| `FI-TRACE-BINDING-CONFLICT` | A binding conflict denies without replacing either existing binding. | -| `FI-TRACE-TOMBSTONE-REPLAY` | Fresh eligible evidence for a retired pair or revoked key denies without recreation. | -| `FI-TRACE-ASSERTION-REFRESH` | Fresh evidence reuses the same eligible durable binding after prior assertion expiry. | -| `FI-TRACE-PREPARED-STALE` | Changed identity-class witnesses or extended bounds deny; provenance-only rotation revalidates. | -| `FI-TRACE-CONCURRENT-ENROLLMENT` | Identical first use converges; conflicting first use commits at most one winner. | -| `FI-TRACE-FINAL-DENIAL-NO-MUTATION` | Every failed phase leaves all authoritative stores and effects unchanged. | -| `FI-TRACE-LIFECYCLE-AUTHORITY` | Unprivileged/stale transitions deny; authorized retirement/revocation/rotation is atomic. | -| `FI-TRACE-LEASE-BOUND` | A lease ends at its earliest bound; equality at any bound is expired. | -| `FI-TRACE-MULTI-KEY-SESSION` | One actor's lease never authorizes another key on the same connection. | -| `FI-TRACE-DENIAL-ORACLE` | Each private row produces its exact fixed bytes on every surface where its condition can be decided — HTTP, a WebSocket upgrade, or after connect; all private-state rows compare byte-identical. | -| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | Each unreadable authoritative dependency denies. | -| `FI-TRACE-AUTHORITY-UNIFORM` | Every protected ingress reaches one current final-admission authority. | -| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal subjects across issuers and equal pairs across domains remain distinct. | +| `FI-TRACE-TRANSPORT-CLOSED` | Exact one-header input succeeds; missing, repeated, combined, malformed, and fallback variants deny. | +| `FI-TRACE-ASSERTION-VALIDATION` | Valid boundary input passes; each signature, key-selection, issuer, audience, time, size, and missing-configuration negative denies. | +| `FI-TRACE-TOKEN-CLASS` | `at+jwt` and `nip-fi+jwt` pass only their selected class; ID tokens, wrong or generic types, and cross-class fallback deny. | +| `FI-TRACE-ASSERTION-KEY-MISMATCH` | Mismatch between `nostr_pubkey` and the NIP-42 proven key denies with the private-state response. | +| `FI-TRACE-JWKS-ADD` | A key added to the JWKS is accepted after the next snapshot refresh. | +| `FI-TRACE-JWKS-REMOVE` | Connections verified under a removed key deny on next revalidation or reconnect. | +| `FI-TRACE-DEPENDENCY-FAIL-CLOSED` | An unreadable JWKS snapshot denies `authorization_unavailable`; no degraded Nostr-only access. | +| `FI-TRACE-LEASE-BOUND` | A session closes at its earliest deadline; equality at any deadline is expired. | +| `FI-TRACE-DENIAL-ORACLE` | Each public-class row produces its exact fixed bytes; all private-state rows compare byte-identical. | +| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes do not expose issuer, audience, or deployment-private state. | +| `FI-TRACE-CROSS-DOMAIN-COLLISION` | Equal `sub` values under different `iss` values remain distinct identities. | | `FI-TRACE-PRIVACY-NONPUBLIC` | Private identity does not enter public surfaces. | -| `FI-TRACE-DISCOVERY-PRIVATE` | Complete discovery bytes remain identical across attested-key, TOFU, and companion enrollment modes. | -| `FI-TRACE-TOFU-THEFT` | Stolen-assertion first use denies unless private TOFU is enabled and the attacker also proves its chosen key. | - -## Relationship to other work (non-normative) - -NIP-FI binds an access token to a key the resource server itself verifies, the -goal DPoP (RFC 9449) and mTLS-bound tokens (RFC 8705) reach through a `cnf` -claim. Here the proof is the NIP-42 or NIP-98 event the relay already -validates, so no second proof is defined and the issuer need not attest the -key; `nostr_pubkey` is the optional `cnf` analogue. Unlike those profiles the -binding is durable server state rather than a per-token claim: a stolen -assertion cannot reach an enrolled identity without its key, and revocation is -a local fact rather than a token-lifetime race. One identity, one key per -domain is stricter than WebAuthn's many-credentials-per-account model because -the Nostr key is itself the public identity; additional devices do not create -additional active bindings. Two contract identities plus explicit dependency -versions exist because folding a mutable key snapshot into policy identity would make benign -rotation change policy lineage, while omitting it would let evidence under a -removed key survive. Denial responses deliberately collapse the conditions that -RFC 6750 error codes distinguish. `trusted-proxy-hmac-v2` in NIP-FI-EDGE is a -fixed-component request MAC in the family of HTTP Message Signatures (RFC 9421) -and AWS SigV4, without negotiation and with length-prefixed canonicalization. ## Security considerations -Issuer compromise can impersonate a principal but cannot prove an uncompromised -bound Nostr key. Assertion theft cannot use an existing binding without that -key; private TOFU intentionally retains first-use theft risk. Snapshot -revalidation limits removed-key reuse but the base policy accepts authenticated -key-source rollback as residual issuer risk. Two-phase admission closes the -binding and policy TOCTOU window only when every authoritative witness is reread -atomically. Availability failures deny rather than degrade to Nostr-only access. +**Assertion theft.** A stolen assertion cannot authorize without also proving +the named `nostr_pubkey` via NIP-42. The relay's assertion–key binding check +is the primary control against assertion replay across keys. + +**TTL window after revocation.** Offline JWT verification means the relay +cannot observe IdP-side revocation until the current assertion expires. The +deployment MUST configure a `max_connection_lifetime_seconds` and +assertion TTL consistent with the organization's acceptable revocation latency. + +For upstream revocation without an explicit disconnect call (issuer stops +issuing assertions; no active session termination), access persists until the +live session's effective authority deadlines expire. After the session closes +naturally, a reconnect requires an assertion that remains valid when reverified. +Previously issued assertions that have not yet expired remain valid for +reconnection until `min(exp, iat + maximum_assertion_age)` (subject to possible +earlier termination from a snapshot refresh failure, hard-deadline expiry without +key replacement, or signing-key removal). Stopping issuance prevents minting +assertions that extend this window; it does not invalidate already-issued +assertions. If the issuer continues issuing assertions, access continues. + +For the session-only disconnect model (issuer issues a successful disconnect +call that closes all matching sessions synchronously), there is no surviving +old-session window. If the issuer also stops issuing new assertions at that +point, cumulative residual access is bounded by: + +``` +max(0, min(exp, iat + maximum_assertion_age) - now) +``` + +`max_connection_lifetime_seconds` only partitions that interval into individual +sessions; it does not shorten the total window. A snapshot refresh failure, +hard-deadline expiry without key replacement, or signing-key removal can +terminate access earlier, but these are not reliable protocol-level bounds: the +JWKS snapshot deadline renews on each refresh even when content is unchanged. +If the issuer continues issuing new assertions after the disconnect call, +cumulative access extends indefinitely — the session-only protocol places no +protocol-level bound on that case. See the non-normative note in the Admin +disconnect section for the open product question on the deny-until-TTL +alternative. + +**SSRF.** The JWKS fetcher implements SSRF protection: HTTPS-only URI +validation, DNS resolution with IP deny-list enforcement, address pinning to +prevent DNS rebinding TOCTOU, and redirect denial. The complete IANA +Special-Purpose address deny table is implemented; see `crates/buzz-core/src/network.rs`. + +**Issuer compromise.** A compromised assertion issuer can impersonate any +identity but cannot prove possession of the assertion-named Nostr key. The NIP-42 +proof remains an independent control. + +**Algorithm confusion.** The verifier enforces asymmetric algorithms only; +`alg=none` and symmetric algorithms deny. The exact `kid`-based key selection +is bounded before any attacker-controlled lookup. ## Sources - NIP-42 authentication: -- NIP-98 HTTP authentication: - JWT BCP: - JWT access-token profile: - DPoP: -- OAuth 2.0 mTLS client certificate-bound tokens: -- HTTP Message Signatures: -- Non-normative composed model: [NIP-FI-MODEL.md](NIP-FI-MODEL.md) diff --git a/docs/push-gateway-deployment.md b/docs/push-gateway-deployment.md index b2b66f5b5b4..c4151a677ce 100644 --- a/docs/push-gateway-deployment.md +++ b/docs/push-gateway-deployment.md @@ -67,6 +67,7 @@ The gateway serves Prometheus metrics at `GET /metrics` on the **private health | Metric | Type | Labels | Meaning | |---|---|---|---| +| `push_gateway_apns_send_attempts_total` | counter | none | Entries into the concrete APNs HTTP send seam. Compare with terminal outcomes to detect work that never reached transport. | | `push_gateway_apns_deliveries_total` | counter | `outcome` = `accepted` \| `invalid_endpoint` \| `retry` \| `configuration_fault` \| `permanent_request_fault` | Terminal APNs send outcomes. | | `push_gateway_apns_delivery_seconds` | histogram | — | APNs send round-trip latency (seconds). | | `push_gateway_admissions_total` | counter | `result` = `admitted` \| `rejected` \| `unavailable` | Outcome at the `authorize_delivery` replay/quota fence. | @@ -74,9 +75,38 @@ The gateway serves Prometheus metrics at `GET /metrics` on the **private health | `push_gateway_reaper_failures_total` | counter | — | Retention reaper sweep failures. | | `push_gateway_readiness_failures_total` | counter | `cause` = `not_accepting` \| `authority` | Readiness probe failures by cause. | -`push_gateway_delivery_errors_total` is intentionally **narrow**: it counts only selected exit classes of the `/v1/deliveries/apns` handler — `class` ∈ `invalid_grant` (grant rejected at the admission seam, before a permit is issued), `temporarily_unavailable` (authority unavailable at the admission seam), `profile_mismatch`, `token_custody` (endpoint-token open failure), `finish_failed` (detached disposition/join failure returned as 503). Request/auth/attestation/grant validation on the enrollment, delegation, rotation, and revocation handlers is **not** counted by this metric; it is a delivery-hot-path signal, not a total error rate across the API. +`push_gateway_delivery_errors_total` is intentionally **narrow**: it counts only selected exit classes of the `/v1/deliveries/apns` handler. `class` ∈ `invalid_grant` (grant rejected at the admission seam, before a permit is issued), `rate_limited`, `temporarily_unavailable` (authority unavailable at the admission seam), `profile_mismatch`, `profile_disabled`, `token_custody` (endpoint-token open failure), `finish_failed` (detached disposition/join failure returned as 503). Request/auth/attestation/grant validation on the enrollment, delegation, rotation, and revocation handlers is **not** counted by this metric; it is a delivery-hot-path signal, not a total error rate across the API. + +Scraping is **opt-in** and off by default, so the default chart render is unchanged and `8081` keeps no pod ingress. For prometheus-operator, set `podMonitor.enabled=true` and `networkPolicy.monitoring.enabled=true` with `networkPolicy.monitoring.namespaceSelector` / `podSelector` naming your scraper. For Datadog Autodiscovery, leave `podMonitor.enabled=false`, supply the OpenMetrics check through `podAnnotations`, and enable the same narrowly selected NetworkPolicy ingress: + +```yaml +podAnnotations: + ad.datadoghq.com/gateway.checks: | + { + "openmetrics": { + "init_config": {}, + "instances": [{ + "openmetrics_endpoint": "http://%%host%%:8081/metrics", + "service": "buzz-push-gateway", + "namespace": "block.buzz_push_gateway", + "metrics": ["push_gateway_.*"], + "histogram_buckets_as_distributions": true, + "send_distribution_buckets": true, + "send_monotonic_counter": true, + "collect_counters_with_distributions": true + }] + } + } +networkPolicy: + monitoring: + enabled: true + namespaceSelector: # replace with the Datadog Agent namespace labels + kubernetes.io/metadata.name: datadog + podSelector: # replace with the Datadog Agent pod labels + app.kubernetes.io/name: datadog-agent +``` -Scraping is **opt-in** and off by default, so the default chart render is unchanged and `8081` keeps no pod ingress. To enable it, set `podMonitor.enabled=true` (renders a prometheus-operator `PodMonitor` scraping the `health` port `/metrics`) and `networkPolicy.monitoring.enabled=true` with `networkPolicy.monitoring.namespaceSelector` / `podSelector` naming your scraper — this adds a single `8081` ingress rule scoped to that source, never a blanket allowance. Node/kubelet-origin probe traffic remains exempt from NetworkPolicy regardless. +Both modes add one `8081` ingress rule scoped to the configured source, never a blanket allowance. Node/kubelet-origin probe traffic remains exempt from NetworkPolicy regardless. Do not enable `PodMonitor` in clusters without its CRD. Alerting rules ship as an opt-in prometheus-operator `PrometheusRule` (`prometheusRule.enabled=true`). Thresholds and operator actions: @@ -189,10 +219,22 @@ The chart defaults to the `main` image tag because `.github/workflows/docker.yml ```bash gh attestation verify \ oci://ghcr.io/block/buzz-push-gateway@sha256:<64-lowercase-hex> \ - --owner block + --repo block/buzz \ + --signer-workflow block/buzz/.github/workflows/docker.yml \ + --source-digest <40-lowercase-hex-source-commit> ``` -Only after that command succeeds, set the exact digest as `image.digest`; the chart then renders `ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. `values-production.yaml` is an intentionally invalid production-input contract: deployment CI must inject this verified `image.digest`, the provisioned dogfood Apple application identifier, an environment-owned Gateway parent reference, and the actual PostgreSQL network. Schema validation rejects the artifact when any remains empty; the render guard proves both rejection and a fully injected render. +Only after that command succeeds, inject the exact digest as `image.digest` in +the environment's GitOps values; the chart then renders +`ghcr.io/block/buzz-push-gateway@sha256:...` and ignores the mutable tag. +`values-production.yaml` remains an intentionally invalid production-input +contract: deployment CI must inject the verified image digest, the provisioned +dogfood Apple application identifier, and the actual PostgreSQL network. In an +environment with an existing ingress or service mesh route, keep +`httpRoute.enabled=false`. If this chart owns a Gateway API route, enable it and +inject an environment-owned `parentRef`; schema validation rejects an enabled +route with no parent. The render guard proves both rejection of missing required +inputs and fully injected renders. Network policy keeps APNs HTTPS and PostgreSQL egress in separate CIDR lists. APNs currently requires broad TCP/443 reachability; `networkPolicy.postgresEgressCidrs` must be narrowed to the production database network, and the DNS namespace/pod selectors must match the cluster DNS deployment. The sample private CIDR is not a claim about the production topology. @@ -201,8 +243,9 @@ Kubernetes does not restart pods when referenced Secret bytes change. AEAD or AP ## Gateway chart release The gateway chart has a collision-free release lane separate from the main -`buzz` chart. To publish version `X.Y.Z`, update both `version` and `appVersion` -in `deploy/charts/buzz-push-gateway/Chart.yaml`, validate the chart, and open a +`buzz` chart. To publish chart version `X.Y.Z`, update `version` in +`deploy/charts/buzz-push-gateway/Chart.yaml` and keep `appVersion` equal to the +gateway binary's workspace package version. Validate the chart, then open a same-repository PR whose branch is exactly `push-chart-release/X.Y.Z`: ```bash @@ -220,3 +263,10 @@ version. The publisher verifies the checked-out commit is the tag target and the chart version equals `X.Y.Z` before pushing `oci://ghcr.io/block/buzz/charts/buzz-push-gateway`. A manually pushed `push-chart-vX.Y.Z` tag is the documented rescue path and runs the same checks. +After the publisher succeeds, inspect and fetch the published chart version +before use: + +```bash +helm show chart oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z +helm pull oci://ghcr.io/block/buzz/charts/buzz-push-gateway --version X.Y.Z +``` diff --git a/migrations/0043_push_gateway_dogfood_profile.sql b/migrations/0043_push_gateway_dogfood_profile.sql new file mode 100644 index 00000000000..5dbed21b193 --- /dev/null +++ b/migrations/0043_push_gateway_dogfood_profile.sql @@ -0,0 +1,19 @@ +-- The internal MVP accepts only the dogfood application profile. The legacy +-- profile names encoded APNs transport environment rather than a verified +-- application identity, so they cannot be mapped safely to dogfood authority. +-- Retire their delegations and installations before narrowing the constraint. +DELETE FROM push_gateway_delegations +WHERE installation_id IN ( + SELECT id + FROM push_gateway_installations + WHERE app_profile <> 'buzz-ios-dogfood' +); + +DELETE FROM push_gateway_installations +WHERE app_profile <> 'buzz-ios-dogfood'; + +ALTER TABLE push_gateway_installations + DROP CONSTRAINT push_gateway_installations_app_profile_check; +ALTER TABLE push_gateway_installations + ADD CONSTRAINT push_gateway_installations_app_profile_check + CHECK (app_profile = 'buzz-ios-dogfood'); diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 739d8730751..3061f92b6a8 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -938,6 +938,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -962,6 +963,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -986,6 +988,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = "$(FLUTTER_BUILD_NAME)"; + OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = "$(BUNDLE_IDENTIFIER).NotificationService"; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/mobile/lib/features/channels/channel_directory.dart b/mobile/lib/features/channels/channel_directory.dart index a99822ede5e..1282e5f1e14 100644 --- a/mobile/lib/features/channels/channel_directory.dart +++ b/mobile/lib/features/channels/channel_directory.dart @@ -150,7 +150,7 @@ class _ChannelRefreshFence { /// nothing awaits it, so a throw would only surface as an unhandled error. /// /// An extension in this part file rather than a method on the notifier because -/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// `channels_provider.dart` sits against the repository-wide 1200-line file /// ceiling enforced by `just file-size-check`. extension _CatchUpFencing on ChannelsNotifier { bool _isCatchUpRetired( @@ -188,7 +188,7 @@ Future _fenced(_ChannelRefreshFence fence, Future future) async { /// profiles in one round-trip. Returns lowercase pubkey to label. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. Future> _resolveDmDisplayNames( RelaySessionNotifier session, _ChannelRefreshFence fence, @@ -271,7 +271,7 @@ Future> _fetchHuddleStarts( /// Counts distinct `p`-tagged members per channel from kind:39002 events. /// /// Lives in this part file to keep `channels_provider.dart` under the -/// repository-wide 1000-line ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line ceiling enforced by `just file-size-check`. Map _memberCountsByChannelId(Iterable memberEvents) { final memberCounts = {}; for (final event in memberEvents) { @@ -297,7 +297,7 @@ Map _memberCountsByChannelId(Iterable memberEvents) { /// filter, so a retired response is discarded rather than merged. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. class _ChannelRefreshCoordinator { /// Resolves the relay-and-identity scope that is active right now. final String Function() currentScope; @@ -429,7 +429,7 @@ Future> _fetchPaginatedChannelEvents( /// Thread-interest and unread helpers shared by [ChannelsNotifier]. /// /// Lives in this part file because `channels_provider.dart` sits against the -/// repository-wide 1000-line file ceiling enforced by `just file-size-check`. +/// repository-wide 1200-line file ceiling enforced by `just file-size-check`. String? _observedUnreadRootId(NostrEvent event) => _isBroadcastReply(event) ? null : event.threadReference.rootId; @@ -456,7 +456,7 @@ String _encodeRootIdSet(Set values) => jsonEncode(values.toList()); /// Records one observed unread event for a channel's badge state. /// /// An extension in this part file rather than a method on the notifier because -/// `channels_provider.dart` sits against the repository-wide 1000-line file +/// `channels_provider.dart` sits against the repository-wide 1200-line file /// ceiling enforced by `just file-size-check`. Private members stay reachable: /// a part shares its parent's library. extension _ObservedUnreadRecording on ChannelsNotifier { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index a696fbe336a..8e724cc3a84 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -233,7 +233,7 @@ class ChannelsNotifier extends AsyncNotifier> { final dedupedMetas = latestMetaPerId.values.toList(); // Resolve DM participant display names. Extracted into the part file so - // `channels_provider.dart` stays under the 1000-line ceiling enforced by + // `channels_provider.dart` stays under the 1200-line ceiling enforced by // `just file-size-check`. final displayNames = await _resolveDmDisplayNames( session, diff --git a/mobile/scripts/check-file-sizes.mjs b/mobile/scripts/check-file-sizes.mjs index 765cd8edcfb..62aa93295ee 100644 --- a/mobile/scripts/check-file-sizes.mjs +++ b/mobile/scripts/check-file-sizes.mjs @@ -1,22 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { - root: "lib", - extensions: new Set([".dart"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Mobile", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/mobile/scripts/file-size-policy.mjs b/mobile/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..2d05dfb4be5 --- /dev/null +++ b/mobile/scripts/file-size-policy.mjs @@ -0,0 +1,7 @@ +export const rules = [ + { + root: "lib", + extensions: new Set([".dart"]), + maxLines: 1200, + }, +]; diff --git a/schema/schema.sql b/schema/schema.sql index 2335f8bf0bd..7d18d825a8b 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -1134,7 +1134,7 @@ CREATE TABLE push_gateway_installations ( app_attest_key_id BYTEA NOT NULL UNIQUE CHECK (octet_length(app_attest_key_id) BETWEEN 1 AND 128), app_attest_public_key BYTEA NOT NULL CHECK (octet_length(app_attest_public_key) BETWEEN 33 AND 256), assertion_counter BIGINT NOT NULL CHECK (assertion_counter BETWEEN 0 AND 4294967295), - app_profile TEXT NOT NULL CHECK (app_profile IN ('buzz-ios-production','buzz-ios-sandbox')), + app_profile TEXT NOT NULL CHECK (app_profile = 'buzz-ios-dogfood'), token_ciphertext BYTEA NOT NULL CHECK (octet_length(token_ciphertext) BETWEEN 1 AND 2048), token_fingerprint BYTEA NOT NULL CHECK (length(token_fingerprint) = 32), endpoint_epoch BIGINT NOT NULL CHECK (endpoint_epoch > 0), diff --git a/scripts/check-file-sizes-core.test.mjs b/scripts/check-file-sizes-core.test.mjs index 9b4b910404d..986d1a3810c 100644 --- a/scripts/check-file-sizes-core.test.mjs +++ b/scripts/check-file-sizes-core.test.mjs @@ -1,9 +1,19 @@ import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { mkdtempSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import test from "node:test"; +import { policy as desktopPolicy } from "../desktop/scripts/check-file-sizes.mjs"; +import { policy as mobilePolicy } from "../mobile/scripts/check-file-sizes.mjs"; +import { policy as webPolicy } from "../web/scripts/check-file-sizes.mjs"; import { allowedLineCount, countLines, @@ -12,6 +22,8 @@ import { resolveBaseRef, } from "./check-file-sizes-core.mjs"; +const repoRoot = path.resolve(import.meta.dirname, ".."); + function git(repo, ...args) { // These fixture repositories inherit both hook configuration and Git's // repository-local environment when this test runs from pre-push. Isolate @@ -26,6 +38,62 @@ function git(repo, ...args) { }).trim(); } +function createEntrypointFixture({ + surface, + files, + lineDelta = 1, + symlinkEntrypoint = false, +}) { + const repo = realpathSync( + mkdtempSync(path.join(tmpdir(), `file-size-${surface}-`)), + ); + const scriptsDir = path.join(repo, "scripts"); + const surfaceScriptsDir = path.join(repo, surface, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + mkdirSync(surfaceScriptsDir, { recursive: true }); + copyFileSync( + path.join(repoRoot, "scripts/check-file-sizes-core.mjs"), + path.join(scriptsDir, "check-file-sizes-core.mjs"), + ); + for (const fileName of ["check-file-sizes.mjs", "file-size-policy.mjs"]) { + copyFileSync( + path.join(repoRoot, surface, "scripts", fileName), + path.join(surfaceScriptsDir, fileName), + ); + } + + git(repo, "init", "-b", "main"); + git(repo, "config", "user.name", "Test"); + git(repo, "config", "user.email", "test@example.com"); + git(repo, "add", "."); + git(repo, "commit", "-m", "base"); + const base = git(repo, "rev-parse", "HEAD"); + git(repo, "switch", "-c", "feature"); + + for (const { relativeFile, maxLines } of files) { + const governedFile = path.join(repo, surface, relativeFile); + const lineCount = maxLines + lineDelta; + mkdirSync(path.dirname(governedFile), { recursive: true }); + writeFileSync(governedFile, `${"line\n".repeat(lineCount - 1)}line`); + } + + const realEntrypointPath = path.join( + surfaceScriptsDir, + "check-file-sizes.mjs", + ); + let entrypointPath = realEntrypointPath; + if (symlinkEntrypoint) { + entrypointPath = path.join(repo, `${surface}-file-size-check.mjs`); + symlinkSync(realEntrypointPath, entrypointPath); + } + const result = spawnSync(realpathSync(process.execPath), [entrypointPath], { + cwd: repo, + encoding: "utf8", + env: { ...process.env, CHECK_FILE_SIZES_BASE: base }, + }); + return { result, relativeFiles: files.map(({ relativeFile }) => relativeFile) }; +} + test("local base resolution uses the branch merge-base and fails without origin/main", () => { const repo = mkdtempSync(path.join(tmpdir(), "file-size-base-")); git(repo, "init", "-b", "main"); @@ -47,12 +115,151 @@ test("local base resolution uses the branch merge-base and fails without origin/ ); }); +const entrypointCases = [ + { + surface: "desktop", + files: [ + { relativeFile: "src-tauri/src/oversized.rs", maxLines: 1500 }, + { relativeFile: "src-tauri/crates/oversized.rs", maxLines: 1500 }, + { relativeFile: "src/app/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/features/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/api/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/shared/context/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/lib/oversized.ts", maxLines: 1200 }, + { relativeFile: "src/shared/ui/oversized.tsx", maxLines: 1200 }, + { relativeFile: "src/shared/styles/oversized.css", maxLines: 1200 }, + ], + }, + { + surface: "mobile", + files: [{ relativeFile: "lib/oversized.dart", maxLines: 1200 }], + }, + { + surface: "web", + files: [ + { relativeFile: "src/app/oversized.ts", maxLines: 1000 }, + { relativeFile: "src/features/oversized.tsx", maxLines: 1000 }, + { relativeFile: "src/shared/api/oversized.ts", maxLines: 1000 }, + ], + }, +]; + +test("surface entrypoints execute every production rule", () => { + for (const fixture of entrypointCases) { + const { result, relativeFiles } = createEntrypointFixture(fixture); + assert.equal( + result.status, + 1, + `${fixture.surface} should reject every ceiling + 1: ${result.stderr || result.stdout}`, + ); + for (const relativeFile of relativeFiles) { + assert.ok( + result.stderr.includes(relativeFile), + `${fixture.surface} should report ${relativeFile}: ${result.stderr}`, + ); + } + } +}); + +test("surface entrypoints execute through symlinked paths", () => { + for (const fixture of entrypointCases) { + const { result, relativeFiles } = createEntrypointFixture({ + ...fixture, + symlinkEntrypoint: true, + }); + assert.equal( + result.status, + 1, + `${fixture.surface} symlink should reject ceiling + 1: ${result.stderr || result.stdout}`, + ); + for (const relativeFile of relativeFiles) { + assert.ok( + result.stderr.includes(relativeFile), + `${fixture.surface} symlink should report ${relativeFile}: ${result.stderr}`, + ); + } + } +}); + +test("surface entrypoints allow every production rule at its ceiling", () => { + for (const fixture of entrypointCases) { + const { result } = createEntrypointFixture({ ...fixture, lineDelta: 0 }); + assert.equal( + result.status, + 0, + `${fixture.surface} should allow every ceiling: ${result.stderr || result.stdout}`, + ); + } +}); + test("counts empty, LF, and CRLF content with the existing semantics", () => { assert.equal(countLines(""), 0); assert.equal(countLines("one\n"), 2); assert.equal(countLines("one\r\ntwo"), 2); }); +test("surface entrypoints expose the exact ordered production policies", () => { + const policies = [ + [ + desktopPolicy, + [ + ["src-tauri/src", [".rs"], 1500], + ["src-tauri/crates", [".rs"], 1500], + ["src/app", [".ts", ".tsx"], 1200], + ["src/features", [".ts", ".tsx"], 1200], + ["src/shared/api", [".ts", ".tsx"], 1200], + ["src/shared/context", [".ts", ".tsx"], 1200], + ["src/shared/lib", [".ts", ".tsx"], 1200], + ["src/shared/ui", [".ts", ".tsx"], 1200], + ["src/shared/styles", [".css"], 1200], + ], + ], + [mobilePolicy, [["lib", [".dart"], 1200]]], + [ + webPolicy, + [ + ["src/app", [".ts", ".tsx"], 1000], + ["src/features", [".ts", ".tsx"], 1000], + ["src/shared/api", [".ts", ".tsx"], 1000], + ], + ], + ]; + + for (const [policy, expectedRules] of policies) { + const actualRules = policy.rules.map((rule) => [ + rule.root, + [...rule.extensions], + rule.maxLines, + ]); + assert.deepEqual( + actualRules, + expectedRules, + `${policy.label} production rules`, + ); + + for (const rule of policy.rules) { + assert.equal( + evaluateFileSize({ + baseLines: null, + candidateLines: rule.maxLines, + maxLines: rule.maxLines, + }).violates, + false, + `${policy.label} ${rule.root} should allow the ceiling`, + ); + assert.equal( + evaluateFileSize({ + baseLines: null, + candidateLines: rule.maxLines + 1, + maxLines: rule.maxLines, + }).violates, + true, + `${policy.label} ${rule.root} should reject ceiling + 1`, + ); + } + } +}); + test("new files use the configured ceiling", () => { assert.equal(allowedLineCount(null, 1000), 1000); assert.deepEqual( diff --git a/scripts/model-capabilities.json b/scripts/model-capabilities.json index eb3a230a4d5..edcf25a7835 100644 --- a/scripts/model-capabilities.json +++ b/scripts/model-capabilities.json @@ -487,6 +487,24 @@ "_provenance": "all axes materialized from family:anthropic-adaptive-xhigh-fable-5", "_source": "models.dev anthropic catalog label; Databricks workspace endpoint is absent from its provider catalog" }, + { + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-fable-5-1", + "registry_label": "Claude Fable 5.1", + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "_provenance": "all axes materialized from family:anthropic-adaptive-xhigh-fable-5", + "_source": "models.dev anthropic catalog label; Databricks workspace endpoint is absent from its provider catalog" + }, { "provider": "databricks_v2", "raw_model_id": "databricks-claude-opus-4-8", diff --git a/scripts/normative-corpus.json b/scripts/normative-corpus.json index 23aedb1e645..b75e4eb0d50 100644 --- a/scripts/normative-corpus.json +++ b/scripts/normative-corpus.json @@ -759,6 +759,26 @@ "registry_label": null } }, + { + "id": "dbv2-claude-fable-5-1-exact-record-probe", + "provider": "databricks_v2", + "raw_model_id": "databricks-claude-fable-5-1", + "_note": "Probes the canonical Databricks Fable 5.1 endpoint record.", + "expect": { + "thinking_mode": "adaptive", + "supported_efforts": [ + "low", + "medium", + "high", + "xhigh", + "max" + ], + "default_effort": "high", + "databricks_v2_wire_route": "anthropic-messages", + "normalization_policy": "none", + "registry_label": "Claude Fable 5.1" + } + }, { "id": "dbv2-claude-opus-4-8-exact-record-probe", "provider": "databricks_v2", @@ -2708,10 +2728,10 @@ } }, { - "id": "dbv2-uc-goose-deepseek-strip-probe", + "id": "dbv2-uc-fqn-deepseek-strip-probe", "provider": "databricks_v2", - "raw_model_id": "data_workflow_tools.goose.goose-deepseek-v4-pro-0813", - "_note": "Probes strip parity on a goose- prefixed UC FQN carrying the deepseek- token.", + "raw_model_id": "system.ai.deepseek-v4-pro-0813", + "_note": "Probes strip parity on a UC FQN carrying the deepseek- token.", "expect": { "thinking_mode": "none", "supported_efforts": [ diff --git a/web/scripts/check-file-sizes.mjs b/web/scripts/check-file-sizes.mjs index 810a2b7ae72..f43b596cd16 100644 --- a/web/scripts/check-file-sizes.mjs +++ b/web/scripts/check-file-sizes.mjs @@ -1,32 +1,21 @@ +import { realpathSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runFileSizeCheck } from "../../scripts/check-file-sizes-core.mjs"; +import { rules } from "./file-size-policy.mjs"; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const projectRoot = path.resolve(__dirname, ".."); +const scriptPath = realpathSync(fileURLToPath(import.meta.url)); +const projectRoot = path.resolve(path.dirname(scriptPath), ".."); -const MAX_LINES = 1000; - -const rules = [ - { - root: "src/app", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/features", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, - { - root: "src/shared/api", - extensions: new Set([".ts", ".tsx"]), - maxLines: MAX_LINES, - }, -]; - -await runFileSizeCheck({ +export const policy = { projectRoot, rules, label: "Web", -}); +}; + +if ( + process.argv[1] && + realpathSync(path.resolve(process.argv[1])) === scriptPath +) { + await runFileSizeCheck(policy); +} diff --git a/web/scripts/file-size-policy.mjs b/web/scripts/file-size-policy.mjs new file mode 100644 index 00000000000..7c1bdd8a5ed --- /dev/null +++ b/web/scripts/file-size-policy.mjs @@ -0,0 +1,19 @@ +const MAX_LINES = 1000; + +export const rules = [ + { + root: "src/app", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, + { + root: "src/features", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, + { + root: "src/shared/api", + extensions: new Set([".ts", ".tsx"]), + maxLines: MAX_LINES, + }, +];