diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index 3a090b3ebd..3db5c6baaa 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -91,7 +91,7 @@ jobs: echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" if [[ "$TAG_PREFIX" == desktop-v ]]; then - echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" + echo "target_sha=${{ github.event.pull_request.head.sha }}" echo "desktop=true" else echo "target_sha=$GITHUB_SHA" @@ -112,6 +112,7 @@ jobs: PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + MERGED_AT: ${{ github.event.pull_request.merged_at }} run: | VERSION="${VERSION#desktop-v}" export VERSION @@ -146,7 +147,17 @@ jobs: exit 1 fi fi - gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ + if ! gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ -f sha="$TARGET_SHA" \ - --silent + --silent; then + # Ref creation is atomic. A concurrent retry may have won the race; + # accept that only when it created the exact immutable ref. + EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG was concurrently created at $TARGET_SHA" + exit 0 + fi + echo "::error::Tag creation failed and $TAG resolves to $EXISTING_SHA (expected $TARGET_SHA)" + exit 1 + fi diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml index eddebea685..61ccc800af 100644 --- a/.github/workflows/desktop-release-candidate.yml +++ b/.github/workflows/desktop-release-candidate.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + pull-requests: read jobs: validate: @@ -20,6 +21,7 @@ jobs: - name: Validate immutable desktop candidate if: startsWith(github.event.pull_request.head.ref, 'version-bump/') env: + GH_TOKEN: ${{ github.token }} VERSION: ${{ github.event.pull_request.head.ref }} run: | VERSION="${VERSION#version-bump/}" diff --git a/AGENTS.md b/AGENTS.md index 157a4bd47a..4b032edc9f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,8 +172,8 @@ place. | `.github/workflows/upstream-sync-merge.yml` | new | The deterministic (01:30) sync stage — the one that preserves the merge parent. Plain git, no AI. Optional `SYNC_PUSH_TOKEN` secret: a branch pushed with `GITHUB_TOKEN` does not start new workflow runs, so set a PAT if CI stops firing on sync PRs | | `.github/workflows/upstream-sync-ci-status.yml` | new | Labels an open sync PR `sync-ci-green`/`sync-ci-red` once checks settle, and re-requests the Copilot review that gh-aw's `reviewers:` fails to attach. Deliberately does not merge | | `migrations/0027_wallet_binding_fts.sql`, `0028_wallet_binding_fts_kind_move.sql` | new, and **kept after the feature was removed** | Search exclusions for the withdrawn NIP-SW wallet binding. They have already run on live databases and sqlx checksums applied migrations, so deleting them breaks startup validation. What they leave behind — a `search_tsv` expression excluding a kind nobody publishes — is inert, and unwinding it would rewrite a generated column across the whole events table for nothing. **Never edit or delete an applied migration**; add a follow-on | -| `migrations/0029_channels_id_lookup_index.sql` | upstream's `0027_channels_id_lookup_index.sql`, **renumbered** | The fork holds 0027 and 0028, so upstream's own new migrations have to arrive above them. See [Upstream migrations arrive renumbered](#upstream-migrations-arrive-renumbered) — this is now a permanent pattern, not a one-off | -| `crates/buzz-db/src/migration.rs` | `migrations.len()` assertion is 29, not upstream's 27; upstream's channel-index assertion reads `migrations[28].version == 29`, and the highest applied version is `Some(29)` | Counts embedded migrations, so it moves whenever *either* side adds one. `0027` landed without bumping it and left the test failing on `main`; fixed in PR #9. Beyond the count, every upstream assertion that indexes `migrations[…]` past 25 or names a version above 26 has to be shifted by the fork's two — see the section below for why the test suite will *not* catch it if you forget | +| `migrations/0029_channels_id_lookup_index.sql`, `0030_long_reaction_payloads.sql` | upstream's `0027_channels_id_lookup_index.sql` and `0028_long_reaction_payloads.sql`, **renumbered**; contents byte-identical | The fork holds 0027 and 0028, so upstream's own new migrations have to arrive above them. Two syncs running, so treat this as the standing cost of the fork's migration block rather than a special case. See [Upstream migrations arrive renumbered](#upstream-migrations-arrive-renumbered) | +| `crates/buzz-db/src/migration.rs` | `migrations.len()` assertion is 30, not upstream's 28; upstream's channel-index assertion reads `migrations[28].version == 29`, its long-reaction assertion reads `migrations[29].version == 30`, and the highest applied version is `Some(30)` | Counts embedded migrations, so it moves whenever *either* side adds one. `0027` landed without bumping it and left the test failing on `main`; fixed in PR #9. Beyond the count, every upstream assertion that indexes `migrations[…]` past 25 or names a version above 26 has to be shifted by the fork's two — see the section below for why the test suite will *not* catch it if you forget | | `.github/workflows/macos-canary.yml` | new; `push` trigger on `main` with desktop path filters | Unsigned macOS canary; upstream only has a *signed* one, which a fork cannot run. Builds automatically when `desktop/**`, `crates/**` or the root `Cargo.*` change, so the newest artifact always matches `main` — it was dispatch-only, and the sole artifact went 13 commits stale. Free: the repo is public, so GitHub-hosted macOS runners are unbilled. Stages the artifact and the usage notes under the product name read from `tauri.conf.json`, not a hardcoded one, so the brand rename below cannot publish a build under the old name. Sets `signingIdentity: "-"` in its inline config and runs **without** `--no-sign`, which would silently discard it; asserts the bundle signature of the `.app` inside the mounted DMG. Its **sidecar list must track upstream's non-Windows lanes**: `tauri.conf.json`'s `externalBin` is shared, and `scripts/bundle-sidecars.sh` exits 1 on a missing binary, so a sidecar upstream adds breaks this workflow without ever conflicting — `buzz-backend-kubernetes` (#4289) did exactly that in the 2026-08-03 sync | | `Dockerfile` | `buzz-paymaster` added to the cargo build, the strip step, and both `COPY` stages | The sponsor ships in the relay's image so there is one publish pipeline and one immutable `:sha-<7>` tag for `deploy-aws.yml` to pin. Four one-line additions, each inside an existing parallel list, so a conflict resolves as *keep ours, take upstream's*. It is **not** the `ENTRYPOINT` — `infra/aws/paymaster.tf` overrides `entryPoint` | | `.github/aw/actions-lock.json` | new | gh-aw action SHA pins | @@ -245,9 +245,16 @@ database**, so the side with *applied history* keeps it — the fork. Same shape collision, opposite resolution, because "already deployed" points at different parties in the two cases. -`0027_channels_id_lookup_index.sql` (upstream #4647) was the first of these, in the -2026-08-05 sync, and it is worth knowing exactly how it fails because **nothing in -the test suite objects**: +It has now happened twice running — `0027_channels_id_lookup_index.sql` (upstream +#4647) in the 2026-08-05 sync, then `0028_long_reaction_payloads.sql` (upstream #3833) +in the 2026-08-06 sync, renumbered to `0029` and `0030`. Expect it on any sync that +touches `migrations/`, and note that the *second* collision is the more dangerous +shape: upstream's 0028 landed on the fork's 0028, so the two files sorted adjacent and +the tree looked plausible. **A new file under `migrations/` is the tripwire — check the +version integer before reading anything else in the diff.** + +The first one is worth keeping in full because it shows exactly how the failure hides +— **nothing in the test suite objects**: - `sqlx::migrate!` accepts duplicate versions at compile time. `MIGRATOR.iter()` simply yields two entries with version 27. @@ -284,13 +291,26 @@ missing this is the live relay failing to start — which is why a new file unde **The renumber is not finished when the file is renamed.** Upstream's own assertions about its new migration are written against upstream's index and version, and they -merge cleanly into a tree where both are wrong. For 0027→0029 that was -`migrations[26].version == 27` → `migrations[28].version == 29`, plus -`applied_versions(…).last() == Some(27)` → `Some(29)`. Grep the test module for the -old integer rather than trusting the diff — these lines arrive as context, not as -conflicts. And whenever the renumbered migration carries a `kind = …` literal or any -other value the fork also touches, re-read the [event-kind](#fork-local-event-kinds) -notes before assuming the two are independent. +merge cleanly into a tree where both are wrong: + +| Sync | Renumber | Assertions that had to move with it | +|------|----------|--------------------------------------| +| 2026-08-05 | 0027 → 0029 | `migrations[26].version == 27` → `migrations[28].version == 29`; `applied_versions(…).last() == Some(27)` → `Some(29)` | +| 2026-08-06 | 0028 → 0030 | `migrations[27].version == 28` → `migrations[29].version == 30`; `migrations.len()` 28 → 30; `applied_versions(…).last()` → `Some(30)` | + +Only `migrations.len()` arrives as a *conflict*; every indexed assertion arrives as +clean context, which is why the diff will not point you at them. Grep the test module +for the old integer instead. A cheap independent check that the rename actually took: + +```bash +ls migrations/*.sql | sed 's|.*/||' | cut -d_ -f1 | sort | uniq -d +``` + +Any output is a duplicate version, and the unit tests will still be green. + +And whenever the renumbered migration carries a `kind = …` literal or any other value +the fork also touches, re-read the [event-kind](#fork-local-event-kinds) notes before +assuming the two are independent. ### Upstream `buzz://` links that are deliberately *not* rebranded diff --git a/Justfile b/Justfile index c3d755ffeb..0a43249d5f 100644 --- a/Justfile +++ b/Justfile @@ -212,9 +212,10 @@ desktop-tauri-test: _ensure-sidecar-stubs desktop-terminal-performance-test: cargo test --manifest-path desktop/src-tauri/crates/buzz-terminal/Cargo.toml --release --test latency g3_renderer_acquire_stays_within_frame_budget -- --ignored --exact --nocapture -# Verify compiled-flag behavior under both compile states (clean + internal). -# Runs the auto-connect compiled-flag test twice with independently supplied -# expected values; build.rs rerun-if-env-changed triggers recompilation. +# Verify compiled-flag behavior under both compile states (clean + capability set). +# Runs the auto-connect and owner-only access focused tests twice with +# independently supplied expected values; build.rs rerun-if-env-changed +# triggers recompilation. desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs #!/usr/bin/env bash set -euo pipefail @@ -223,10 +224,22 @@ desktop-tauri-test-compiled-flags: _ensure-sidecar-stubs env -u BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=false \ cargo test compiled_flag_matches_expected -- --ignored --nocapture - echo "=== Internal build (flag set) → expect true ===" + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test --lib + env -u BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=false \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture + echo "=== Internal build (flags set) → expect true ===" BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY=1 \ BUZZ_TEST_EXPECTED_AUTO_CONNECT_DEFAULT_RELAY=true \ cargo test compiled_flag_matches_expected -- --ignored --nocapture + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test --lib + BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY=1 \ + BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY=true \ + cargo test compiled_policy_matches_expected -- --ignored --nocapture echo "Both compiled states verified." # Build the full desktop Tauri app locally (unsigned, for testing) diff --git a/RELEASING.md b/RELEASING.md index 23dacea2ce..53d5805561 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -48,28 +48,30 @@ or mobile GitHub Release. ### Desktop 1. Run `just release-desktop ` from a clean, up-to-date `main` checkout. - The script fetches the current `origin/main`, regenerates - `version-bump/` as one - deterministic candidate commit, records the frozen base and proposed - `desktop-v` tag in `.release/desktop-candidate.json`, updates every - desktop manifest and lockfile, writes a full-SHA changelog, and opens or - updates the PR. -2. Review the recorded base and candidate SHA, the complete changelog, and CI. - The required **Desktop Release Candidate** check validates the exact head. - A trusted repository member, owner, or collaborator must approve that exact - candidate head. Any regeneration or push changes the head, invalidates the - prior approval, and requires both the checks and approval to run again. -3. **Squash merge** the PR. The protected branch must still be exactly the - recorded base; otherwise regenerate the candidate from current `main`. -4. `auto-tag-on-release-pr-merge` verifies the frozen parent, full-tree identity, - required checks, and trusted approval on the exact candidate head, then tags - the squash commit as `desktop-v`. An admin or ruleset bypass does not - authorize desktop tagging. -5. The tag triggers `release.yml`. It builds and stages Apple Silicon and Intel - macOS, Windows, and Linux artifacts; publishes the versioned release only - after the complete set succeeds; then updates the rolling updater manifest - last for stable versions. A failed platform leaves no partially published - versioned release. + The script creates one deterministic candidate commit and records both its + frozen base and the verified prior release ledger in candidate metadata. +2. Review the exact candidate SHA, complete changelog, and CI. Regenerating or + pushing the branch creates a new candidate and requires checks to run again. +3. **Squash merge** the PR after all protected-branch checks pass. The merge is + the human authorization event; an authorized owner/admin bypass is treated + the same way. Unrelated changes reaching `main` do not invalidate the + reviewed candidate. +4. `auto-tag-on-release-pr-merge` verifies the closed event against GitHub's PR + identity, validates candidate content, and proves every required check came + from its trusted producer and was successful when the PR merged. It creates + `desktop-v` at the exact reviewed PR head—not the squash commit. + Retries accept that tag only at the same SHA and never move it. GitHub does + not expose when an individual check rerun was created, so an ordinary rerun + after merge deliberately makes tag verification fail closed; inspect that + run and create a new candidate version rather than retrying the blocked tag. +5. The tag triggers `release.yml`. It builds and stages all platform artifacts, + publishes the versioned release only after the complete set succeeds, then + updates the rolling updater manifest last for stable versions. + +Because squash merging leaves immutable candidate tags on side history, the next +release uses validated prior candidate metadata as its ledger boundary. It +includes unrelated commits after the prior frozen base and excludes exactly the +prior release's recorded squash commit; tag ancestry is deliberately irrelevant. ### Relay diff --git a/TESTING.md b/TESTING.md index 51a5eb44c1..764b86d408 100644 --- a/TESTING.md +++ b/TESTING.md @@ -278,6 +278,7 @@ out of the box with `just setup` or `just relay`. Common overrides: | `BUZZ_REQUIRE_AUTH_TOKEN` | `false` | When true, REST requires NIP-98 (no `X-Pubkey` fallback) | | `BUZZ_REQUIRE_RELAY_MEMBERSHIP` | `false` | When true, only pubkeys in `relay_members` can connect | | `BUZZ_REQUIRE_MEDIA_GET_AUTH` | `false` | When true, `GET`/`HEAD /media/*` require Blossom kind 24242 `t=get` auth plus relay membership. | +| `BUZZ_DRAIN_JITTER_MS` | `0` (off) | Per-connection upper bound, in ms, for the random delay before each live WebSocket gets its `1012 Service Restart` close on graceful shutdown. `0` closes every socket at once (the previous behavior). A positive value spreads closes uniformly over `[1, value]` ms to avoid a reconnect thundering herd on rolling deploys. Values above `20000` are capped to `20000` (`MAX_DRAIN_JITTER_MS`) to leave close-frame delivery headroom under the relay's 30s hard-drain timeout. Empty or whitespace-only is treated as unset (off); a non-integer fails startup loudly. | | `BUZZ_AUDIT_ENABLED` | `true` | Tamper-evident event/media audit log. Set `false`/`0`/`off` to skip its DB pool and writes. Does not disable the separate moderation audit trail. | | `BUZZ_AUTO_MIGRATE` | `false` | Opt in with `true`/`1`/`yes`/`on` to run embedded SQLx migrations on relay startup | | `RELAY_OWNER_PUBKEY` | unset | Bootstrapped as `owner` in `relay_members` at first start | diff --git a/VISION.md b/VISION.md index 900e5a9475..66a106bdeb 100644 --- a/VISION.md +++ b/VISION.md @@ -39,7 +39,7 @@ The relay enforces all access control. Channel membership is the only gate. | Type | Visibility | Join | Create | |------|-----------|------|--------| | **Open channels** | Searchable by all members | Self-join | Any member | -| **Private channels** | Hidden, invite-only | Invited by member | Any member | +| **Private channels** | Hidden, invite-only | Invited by an owner/admin | Any member | | **DMs** | Participants only | N/A (up to 9) | Any member | | **Guests** | Scoped to specific channels | Invited | N/A | diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 700d5e8dcf..93109fa94d 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -155,7 +155,7 @@ pub struct AcpClient { /// a `cancelled` outcome before the agent returns from `session/prompt`. pending_permission_id: Option, /// Whether we have already sent a response to the pending permission request. - /// Guards against double-response if a timeout fires after the allow_once + /// Guards against double-response if a timeout fires after the rejection /// response was written but before `pending_permission_id` was cleared. permission_responded: bool, /// The JSON-RPC id of the most recently sent `session/prompt` request. @@ -1162,7 +1162,8 @@ impl AcpClient { /// /// While waiting, handles: /// - `session/update` notifications → logged via tracing - /// - `session/request_permission` requests → auto-approved with `allow_once` + /// - `session/request_permission` requests → rejected unless an owner has + /// already selected a non-interactive permission mode at session setup /// - Any other messages → debug-logged and ignored; if they carry an `id` /// (i.e. they are requests, not notifications), a JSON-RPC -32601 error is sent. /// @@ -1870,12 +1871,12 @@ impl AcpClient { } } - /// Auto-approve a `session/request_permission` request from the agent. + /// Reject a `session/request_permission` request from the agent. /// - /// Finds the option with `kind == "allow_once"` and responds with its `optionId`. - /// If no `allow_once` option exists, falls back to `reject_once`. - /// - /// **Critical:** Never hardcode `optionId` — always find it dynamically by `kind`. + /// Buzz has no human permission prompt in this harness, so selecting + /// `allow_once` would turn any admitted prompt into an implicit approval. + /// Find `reject_once` by kind when the adapter offers it; otherwise use the + /// protocol's cancelled outcome, which is also fail-closed. /// /// The request `id` is stored as `serde_json::Value` to support both numeric /// and string IDs per JSON-RPC 2.0. @@ -1901,40 +1902,7 @@ impl AcpClient { options.len() ); - // Find allow_once by kind — NEVER hardcode optionId. - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - - let response = if let Some(opt) = allow_once { - let option_id = opt["optionId"] - .as_str() - .ok_or_else(|| AcpError::Protocol("allow_once option missing optionId".into()))?; - tracing::info!( - target: "acp::permission", - "auto-approving permission id={id} with allow_once optionId={option_id:?}" - ); - permission_response_selected(&id, option_id) - } else { - // No allow_once — fall back to reject_once. - tracing::warn!( - target: "acp::permission", - "no allow_once option found in permission request id={id}, falling back to reject_once" - ); - let reject = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - - if let Some(opt) = reject { - let option_id = opt["optionId"].as_str().unwrap_or("reject"); - permission_response_selected(&id, option_id) - } else { - return Err(AcpError::Protocol( - "no suitable permission option found (neither allow_once nor reject_once)" - .into(), - )); - } - }; + let response = permission_denial_response(&id, options)?; // Write the response first, then mark as responded. // @@ -2046,6 +2014,42 @@ fn permission_response_cancelled(id: &serde_json::Value) -> serde_json::Value { }) } +/// Choose the fail-closed response to a `session/request_permission` request. +/// +/// Buzz has no human permission prompt in this harness, so selecting +/// `allow_once` would turn any admitted prompt into an implicit approval. +/// Prefer the adapter's `reject_once` option — matched by `kind`, never by a +/// hardcoded `optionId` — and fall back to the protocol's cancelled outcome for +/// adapters that do not offer one. Both answers deny. +/// +/// Kept free of the client so the decision is testable without an agent +/// subprocess: `AcpClient` owns a real `Child` and its stdio pipes. +fn permission_denial_response( + id: &serde_json::Value, + options: &[serde_json::Value], +) -> Result { + let reject_once = options + .iter() + .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); + + let Some(opt) = reject_once else { + tracing::warn!( + target: "acp::permission", + "no reject_once option found in permission request id={id}, cancelling" + ); + return Ok(permission_response_cancelled(id)); + }; + + let option_id = opt["optionId"] + .as_str() + .ok_or_else(|| AcpError::Protocol("reject_once option missing optionId".into()))?; + tracing::info!( + target: "acp::permission", + "rejecting permission id={id} with reject_once optionId={option_id:?}" + ); + Ok(permission_response_selected(id, option_id)) +} + /// Full `session/new` response — session ID plus the raw JSON result. /// /// Callers use the extractor helpers to pull model info from `raw`. @@ -2300,63 +2304,96 @@ mod tests { assert_eq!(StopReason::from_str("Refusal"), Some(StopReason::Refusal)); } + fn options(json: &str) -> Vec { + serde_json::from_str(json).expect("option list") + } + + fn outcome(response: &serde_json::Value) -> Option<&str> { + response["result"]["outcome"]["outcome"].as_str() + } + + /// The offered `allow_once` and `allow_always` options must be ignored: + /// there is no human to click them, so choosing either would make every + /// admitted prompt an implicit approval. `optionId`s are deliberately + /// non-obvious to prove they are matched by `kind`, never hardcoded. #[test] - fn find_allow_once_by_kind_not_by_option_id() { - // optionId values are intentionally non-obvious to prove we don't hardcode them. - let options: Vec = serde_json::from_str( + fn permission_requests_select_reject_once_not_allow_once() { + let options = options( r#"[ {"optionId": "opt-reject-42", "name": "Reject", "kind": "reject_once"}, {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = + permission_denial_response(&serde_json::json!(7), &options).expect("denial response"); - assert!(allow_once.is_some(), "should find allow_once option"); - let opt = allow_once.unwrap(); - // Found by kind, not by hardcoded optionId - assert_eq!(opt["kind"].as_str(), Some("allow_once")); - assert_eq!(opt["optionId"].as_str(), Some("opt-allow-99")); + assert_eq!(outcome(&response), Some("selected")); + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("opt-reject-42"), + "must select reject_once even when allow options are offered" + ); } + /// Fail-closed backstop: an adapter that offers no `reject_once` must still + /// be denied, via the protocol's cancelled outcome rather than an error or + /// an approval. #[test] - fn find_allow_once_returns_none_when_absent() { - let options: Vec = serde_json::from_str( + fn permission_request_without_reject_once_is_cancelled() { + let options = options( r#"[ - {"optionId": "reject-1", "name": "Reject", "kind": "reject_once"}, - {"optionId": "reject-always", "name": "Always reject", "kind": "reject_always"} + {"optionId": "opt-allow-99", "name": "Allow once", "kind": "allow_once"}, + {"optionId": "opt-always-7", "name": "Always allow", "kind": "allow_always"} ]"#, - ) - .unwrap(); + ); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); + let response = permission_denial_response(&serde_json::json!("req-1"), &options) + .expect("cancelled response"); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + assert_eq!( + response["id"].as_str(), + Some("req-1"), + "string ids must round-trip per JSON-RPC 2.0" + ); } + /// An empty option list is the degenerate form of the same backstop. #[test] - fn find_reject_once_fallback_when_no_allow_once() { - let options: Vec = serde_json::from_str( - r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#, - ) - .unwrap(); + fn permission_request_with_no_options_is_cancelled() { + let response = + permission_denial_response(&serde_json::json!(1), &[]).expect("cancelled response"); - let allow_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("allow_once")); - assert!(allow_once.is_none()); + assert_eq!(outcome(&response), Some("cancelled")); + } - let reject_once = options - .iter() - .find(|opt| opt.get("kind").and_then(|k| k.as_str()) == Some("reject_once")); - assert!(reject_once.is_some()); - assert_eq!(reject_once.unwrap()["optionId"].as_str(), Some("rej-x")); + /// A `reject_once` option missing its `optionId` is a protocol violation. + /// Erroring propagates to the caller, which tears the turn down — still no + /// approval is ever sent. + #[test] + fn reject_once_without_option_id_is_a_protocol_error() { + let options = options(r#"[{"name": "Reject", "kind": "reject_once"}]"#); + + let err = permission_denial_response(&serde_json::json!(1), &options) + .expect_err("missing optionId must error"); + + assert!(matches!(err, AcpError::Protocol(_)), "got {err:?}"); + } + + #[test] + fn find_reject_once_by_kind() { + let options = + options(r#"[{"optionId": "rej-x", "name": "Reject", "kind": "reject_once"}]"#); + + let response = + permission_denial_response(&serde_json::json!(1), &options).expect("denial response"); + + assert_eq!( + response["result"]["outcome"]["optionId"].as_str(), + Some("rej-x") + ); } #[test] diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 35aaec188d..d959685846 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -116,7 +116,6 @@ impl std::fmt::Display for RespondTo { /// /// - `default` — agent's built-in behaviour (permission requests per tool call). /// - `acceptEdits` — auto-approve file edits, still ask for other tools. -/// - `bypassPermissions` — skip the permission flow entirely. /// - `dontAsk` — never prompt; reject anything that would require permission. /// - `plan` — planning-only mode (no tool execution). #[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)] @@ -127,9 +126,6 @@ pub enum PermissionMode { /// Auto-approve file edits, still ask for other tools. #[value(alias = "acceptEdits")] AcceptEdits, - /// Skip the permission flow entirely. - #[value(alias = "bypassPermissions")] - BypassPermissions, /// Never prompt; reject anything that would require permission. #[value(alias = "dontAsk")] DontAsk, @@ -145,7 +141,6 @@ impl PermissionMode { match self { Self::Default => "default", Self::AcceptEdits => "acceptEdits", - Self::BypassPermissions => "bypassPermissions", Self::DontAsk => "dontAsk", Self::Plan => "plan", } @@ -432,13 +427,12 @@ pub struct CliArgs { /// Permission mode for agents that support `session/set_config_option` /// with `configId: "mode"` (e.g. `claude-agent-acp`). /// - /// Defaults to `bypassPermissions` which skips the per-tool-call - /// permission flow. Set to `default` to restore the agent's built-in - /// behaviour. + /// Defaults to `dontAsk`, which rejects operations that need interactive + /// approval because Buzz does not expose a human permission prompt. #[arg( long, env = "BUZZ_ACP_PERMISSION_MODE", - default_value = "bypass-permissions", + default_value = "dont-ask", value_enum )] pub permission_mode: PermissionMode, @@ -1469,7 +1463,7 @@ mod tests { memory_enabled: true, model: None, session_title: None, - permission_mode: PermissionMode::BypassPermissions, + permission_mode: PermissionMode::DontAsk, respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: Vec::new(), @@ -2270,10 +2264,6 @@ channels = "ALL" fn test_permission_mode_wire_strings() { assert_eq!(PermissionMode::Default.as_wire_str(), "default"); assert_eq!(PermissionMode::AcceptEdits.as_wire_str(), "acceptEdits"); - assert_eq!( - PermissionMode::BypassPermissions.as_wire_str(), - "bypassPermissions" - ); assert_eq!(PermissionMode::DontAsk.as_wire_str(), "dontAsk"); assert_eq!(PermissionMode::Plan.as_wire_str(), "plan"); } @@ -2281,7 +2271,6 @@ channels = "ALL" #[test] fn test_permission_mode_is_default() { assert!(PermissionMode::Default.is_default()); - assert!(!PermissionMode::BypassPermissions.is_default()); assert!(!PermissionMode::AcceptEdits.is_default()); assert!(!PermissionMode::DontAsk.is_default()); assert!(!PermissionMode::Plan.is_default()); @@ -2289,20 +2278,17 @@ channels = "ALL" #[test] fn test_permission_mode_display() { - assert_eq!( - format!("{}", PermissionMode::BypassPermissions), - "bypassPermissions" - ); + assert_eq!(format!("{}", PermissionMode::DontAsk), "dontAsk"); assert_eq!(format!("{}", PermissionMode::Default), "default"); } #[test] fn test_summary_includes_permission_mode() { let mut config = test_config(SubscribeMode::Mentions); - config.permission_mode = PermissionMode::BypassPermissions; + config.permission_mode = PermissionMode::DontAsk; let s = config.summary(); assert!( - s.contains("permission_mode=bypassPermissions"), + s.contains("permission_mode=dontAsk"), "summary should include permission_mode, got: {s}" ); } @@ -2319,9 +2305,9 @@ channels = "ALL" } #[test] - fn test_default_config_uses_bypass_permissions() { + fn test_default_config_rejects_interactive_permissions() { let config = test_config(SubscribeMode::Mentions); - assert_eq!(config.permission_mode, PermissionMode::BypassPermissions); + assert_eq!(config.permission_mode, PermissionMode::DontAsk); } #[test] @@ -2332,7 +2318,6 @@ channels = "ALL" let cases = [ ("default", PermissionMode::Default), ("accept-edits", PermissionMode::AcceptEdits), - ("bypass-permissions", PermissionMode::BypassPermissions), ("dont-ask", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2347,14 +2332,12 @@ channels = "ALL" #[test] fn test_permission_mode_value_enum_camel_case_aliases() { - // Operators may set env vars using the camelCase wire-format strings - // (e.g. BUZZ_ACP_PERMISSION_MODE=bypassPermissions). The #[value(alias)] - // attributes ensure these parse correctly. + // Operators may set env vars using the camelCase wire-format strings. + // The #[value(alias)] attributes ensure these parse correctly. use clap::ValueEnum; let cases = [ ("default", PermissionMode::Default), ("acceptEdits", PermissionMode::AcceptEdits), - ("bypassPermissions", PermissionMode::BypassPermissions), ("dontAsk", PermissionMode::DontAsk), ("plan", PermissionMode::Plan), ]; @@ -2367,6 +2350,18 @@ channels = "ALL" } } + #[test] + fn test_permission_mode_rejects_unattended_bypass() { + use clap::ValueEnum; + + for input in ["bypass-permissions", "bypassPermissions"] { + assert!( + PermissionMode::from_str(input, true).is_err(), + "{input:?} must not disable the ACP permission boundary" + ); + } + } + /// Helper: resolve idle_timeout_secs using the same precedence logic as Config::from_args. /// Precedence: explicit --idle-timeout > --turn-timeout (deprecated) > `DEFAULT_IDLE_TIMEOUT_SECS`. fn resolve_idle_timeout(idle: Option, turn: Option) -> u64 { diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 811253e4ac..65c9dd6203 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -361,50 +361,242 @@ async fn check_sibling_via_profile( false } -const OBSERVER_PUBLISH_INTERVAL: Duration = Duration::from_millis(167); -const OBSERVER_PUBLISH_LIMIT_PER_MINUTE: usize = 90; +/// Observer frames are published at a global rate of AT MOST ONE relay frame +/// per tick — not one per channel, and not one per drain. Everything that +/// accumulates between ticks waits in [`ObserverPublishQueue`] as events and +/// is packed greedily into that single frame. One update per second is smooth +/// enough for a human watching the session viewer, and the global budget is +/// what makes the relay cost model flat: observer frames bill the agent's +/// `LimitType::Messages` quota (`agent_standard_messages_per_min` = 120, +/// enforced in relay `connection.rs::enforce_ws_admission`), shared with the +/// agent's real chat messages. At 1 frame/s telemetry spends at most 60/min — +/// half that budget — regardless of how many channels are active. A slower +/// tick (e.g. 2s → 30/min) would leave more quota headroom for chat at the +/// price of doubled viewer latency; this constant is the knob. +const OBSERVER_PUBLISH_TICK: Duration = Duration::from_secs(1); + +/// Byte budget for EVERYTHING retained while awaiting a publish slot: the +/// event FIFO (serialized, post-`fit_observer_event_to_budget` bytes) PLUS +/// the chunk coalescer's pending buffer (serialized event skeletons + raw +/// accumulated text). Both stores count against this one cap — a +/// high-cardinality chunk flood (many distinct coalescer keys) is bounded +/// exactly like a plain event flood; neither buffer is a bypass around the +/// other. Lossless-ness is bounded by this budget: each publish slot packs +/// one ~64KB frame, gathered queue-wide for the front channel, so a single +/// channel drains at ~64KB/s and 4 MiB buys roughly **64 seconds** of +/// sustained over-production before the oldest items are dropped WITH +/// accounting (a warn carrying the dropped-event count). With C channels +/// producing concurrently the slots round-robin between them, so the +/// per-channel drain is ~64KB/Cs and the budget shortens accordingly — +/// still bytes-per-slot, never events-per-slot (see +/// [`ObserverPublishQueue::next_frame`]). Beyond-budget floods therefore +/// degrade to designed, visible loss — strictly better than the +/// pre-batching pacer's silent 90/min drop. +const OBSERVER_PENDING_QUEUE_MAX_BYTES: usize = 4 * 1024 * 1024; + +/// Observer event kind for a batch envelope wrapping multiple events. +/// +/// The payload is `{"events": [, ...]}` with every inner event +/// carrying its own `seq`/`timestamp`, so consumers process inner events +/// exactly as they would unbatched ones. Single pending events are published +/// unwrapped, so the envelope only appears when there is something to batch. +const OBSERVER_BATCH_KIND: &str = "batch"; -struct ObserverPublishPacer { - next_publish: tokio::time::Instant, - published: VecDeque, +/// Collects observer events awaiting a publish slot. +/// +/// Chunk-type events ride the [`ObserverChunkCoalescer`]; everything else is +/// appended in arrival order, force-flushing pending chunks first — the same +/// ordering rule the pre-batching publisher enforced, so merged chunk text can +/// never leapfrog a tool call that arrived mid-stream. +/// +/// Events wait here as EVENTS, not pre-sealed frames: each publish slot packs +/// one frame at publish time ([`Self::next_frame`]), so a backlog keeps +/// compacting into full frames instead of freezing into a frame queue. +/// +/// The queue is bounded by [`OBSERVER_PENDING_QUEUE_MAX_BYTES`]. When a +/// sustained flood outruns the one-frame-per-tick drain for longer than the +/// budget, the OLDEST events are dropped (the viewer wants recent state) with +/// accounting: a warning carrying the dropped-event count, and +/// `dropped_events` for tests. +#[derive(Default)] +struct ObserverPublishQueue { + coalescer: ObserverChunkCoalescer, + /// `(serialized_len, source_events, event)`, oldest first. Length is + /// captured at enqueue (post-fit) so byte accounting never re-serializes + /// on eviction; `source_events` is how many GENERATED observer events the + /// entry represents (a merged chunk carries every chunk it absorbed), so + /// eviction accounting stays in source units after flush. + events: VecDeque<(usize, u64, observer::ObserverEvent)>, + pending_bytes: usize, + /// SOURCE observer events lost to byte-budget eviction. Counted in + /// generated-event units, not retained entries: a coalesced entry that + /// merged N chunks accounts for N when evicted. A PUBLISHED merged entry + /// delivers all N sources' text in one event, so the invariant is + /// `ingested == dropped_events + Σ source_events over published events`. + dropped_events: u64, } -impl ObserverPublishPacer { - fn new() -> Self { - Self { - // No initial burst: even the first snapshot frame waits for its slot. - next_publish: tokio::time::Instant::now() + OBSERVER_PUBLISH_INTERVAL, - published: VecDeque::with_capacity(OBSERVER_PUBLISH_LIMIT_PER_MINUTE), +impl ObserverPublishQueue { + fn ingest(&mut self, event: observer::ObserverEvent) { + // ObserverChunkCoalescer::ingest returns immediately-publishable events + // (force-flushed pending chunks + non-chunk passthrough, or a pending + // set displaced by the 60KB pre-flush); they join the queue in the + // order the coalescer emitted them, each carrying the count of source + // events it represents. + for (source_events, ready) in self.coalescer.ingest(event) { + self.enqueue(source_events, ready); } + self.enforce_byte_budget(); } - async fn wait(&mut self) { - loop { - let now = tokio::time::Instant::now(); - while self - .published - .front() - .is_some_and(|sent| now.duration_since(*sent) >= Duration::from_secs(60)) - { - self.published.pop_front(); + fn enqueue(&mut self, source_events: u64, mut event: observer::ObserverEvent) { + // Pre-trim at enqueue so (a) byte accounting reflects what will ship + // and (b) one oversized leaf cannot force every frame it touches into + // whole-envelope elision downstream. + fit_observer_event_to_budget(&mut event); + let bytes = serialized_len(&event); + self.pending_bytes += bytes; + self.events.push_back((bytes, source_events, event)); + } + + /// Total bytes retained across BOTH stores — the event FIFO and the + /// coalescer's pending chunk buffer. The budget binds this sum; counting + /// only the FIFO would let a high-cardinality chunk flood (many distinct + /// coalescer keys, nothing ever flushing) grow unbounded outside the cap. + fn total_pending_bytes(&self) -> usize { + self.pending_bytes + self.coalescer.pending_bytes + } + + /// Enforce [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] over the total, dropping + /// OLDEST items first with accounting in SOURCE-event units. Global age + /// order across the two stores is structural: every enqueue path flushes + /// the coalescer first, so every pending coalescer entry is strictly newer + /// than every queued event — eviction is queue front, then coalescer + /// front. The `> 1` guard never drops the sole remaining item (any single + /// fitted event or pre-flush-capped chunk entry is far under the budget). + fn enforce_byte_budget(&mut self) { + let mut dropped = 0u64; + while self.total_pending_bytes() > OBSERVER_PENDING_QUEUE_MAX_BYTES + && self.events.len() + self.coalescer.pending.len() > 1 + { + if let Some((bytes, source_events, _)) = self.events.pop_front() { + self.pending_bytes -= bytes; + dropped += source_events; + } else { + dropped += self.coalescer.drop_oldest().expect("guard ensures an item"); } + } + if dropped > 0 { + self.dropped_events += dropped; + tracing::warn!( + dropped, + total_dropped = self.dropped_events, + pending_bytes = self.total_pending_bytes(), + "observer publish queue over byte budget; dropped oldest events" + ); + } + } - let minute_slot = self.published.front().and_then(|sent| { - (self.published.len() >= OBSERVER_PUBLISH_LIMIT_PER_MINUTE) - .then_some(*sent + Duration::from_secs(60)) - }); - let publish_at = - minute_slot.map_or(self.next_publish, |slot| slot.max(self.next_publish)); - if publish_at > now { - tokio::time::sleep_until(publish_at).await; - continue; - } + /// True when nothing is waiting anywhere — the event queue AND the + /// coalescer's pending chunk buffer. + fn is_empty(&self) -> bool { + self.events.is_empty() && self.coalescer.pending.is_empty() + } - let published_at = tokio::time::Instant::now(); - self.published.push_back(published_at); - self.next_publish = published_at + OBSERVER_PUBLISH_INTERVAL; - return; + /// Pack and remove AT MOST ONE publishable frame: the front event's + /// channel, gathered queue-wide in FIFO order (packed greedily until + /// adding the next event would push the envelope over + /// `OBSERVER_MAX_PLAINTEXT_LEN`). Singletons ship unwrapped. + /// + /// Two invariants bound the gather: + /// - A frame never mixes channels (the desktop archive indexes a frame + /// under its decrypted top-level `channelId`), and events keep their + /// FIFO order *within* each channel. Cross-channel frame order MAY + /// differ from arrival order — the desktop tolerates that everywhere: + /// the transcript store sorts + rebuilds on out-of-order arrival, the + /// archive is per-channel by construction, and the turn store's + /// watermark is keyed per (agent, channel). + /// - A NULL-channel event is a BARRIER nothing gathers across: null-scope + /// events (`agent_panic`-class) can causally couple to any channel, so + /// their relative order against every channel is preserved exactly. + /// Null-channel events themselves ship only as their contiguous front + /// run. + /// + /// Gathering queue-wide (not just the front run) is what keeps the drain + /// rate in BYTES per slot rather than front-run-length events per slot: + /// with round-robin producers (channel A, B, A, B, ...) a front-run + /// packer degrades to ~1 event per slot regardless of size, silently + /// growing latency without ever tripping the byte budget. + /// + /// Pending coalesced chunks are flushed into the queue first, so a + /// publish slot never leaves merged chunk text stranded behind the tick. + fn next_frame(&mut self) -> Option { + for (source_events, ready) in self.coalescer.flush() { + self.enqueue(source_events, ready); } + let channel = self.events.front()?.2.channel_id.clone(); + + let mut picked: Vec = Vec::new(); + let mut kept: VecDeque<(usize, u64, observer::ObserverEvent)> = + VecDeque::with_capacity(self.events.len()); + let mut gathering = true; + while let Some((bytes, source_events, event)) = self.events.pop_front() { + if gathering && event.channel_id == channel { + picked.push(event); + if picked.len() > 1 + && serialized_len(&batch_envelope(&picked)) > OBSERVER_MAX_PLAINTEXT_LEN + { + // Frame full: the overflow event stays queued and leads + // its channel's next slot. + let event = picked.pop().expect("len > 1"); + kept.push_back((bytes, source_events, event)); + gathering = false; + } else { + self.pending_bytes -= bytes; + } + } else { + if gathering && (channel.is_none() || event.channel_id.is_none()) { + // Null-channel barrier (or, for a null-channel frame, the + // end of its contiguous front run): stop gathering. + gathering = false; + } + kept.push_back((bytes, source_events, event)); + } + } + self.events = kept; + Some(seal_batch(picked)) + } +} + +/// A single event ships unwrapped; two or more get the batch envelope. +fn seal_batch(mut events: Vec) -> observer::ObserverEvent { + if events.len() == 1 { + return events.pop().expect("len == 1"); + } + batch_envelope(&events) +} + +/// Build the batch envelope for a set of same-channel events. +/// +/// Envelope metadata mirrors the LAST inner event — the same convention the +/// chunk coalescer uses for merged chunks — so `(timestamp, seq)` ordering and +/// the desktop's latest-live-session tracking see the newest state. +fn batch_envelope(events: &[observer::ObserverEvent]) -> observer::ObserverEvent { + let last = events + .last() + .expect("batch envelope needs at least 1 event"); + observer::ObserverEvent { + seq: last.seq, + timestamp: last.timestamp.clone(), + kind: OBSERVER_BATCH_KIND.to_string(), + agent_index: last.agent_index, + channel_id: last.channel_id.clone(), + session_id: last.session_id.clone(), + turn_id: last.turn_id.clone(), + started_at: last.started_at.clone(), + payload: serde_json::json!({ + "events": serde_json::to_value(events).unwrap_or_default(), + }), } } @@ -445,29 +637,26 @@ async fn run_relay_observer_publisher( owner_pubkey_hex: String, owner_pubkey: PublicKey, ) { - let mut coalescer = ObserverChunkCoalescer::default(); - let mut pacer = ObserverPublishPacer::new(); + let mut queue = ObserverPublishQueue::default(); let max_snapshot_seq = snapshot.iter().map(|event| event.seq).max().unwrap_or(0); for event in snapshot { - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, - &keys, - &agent_pubkey_hex, - &owner_pubkey_hex, - &owner_pubkey, - &mut pacer, - event, - ) - .await; - } + queue.ingest(event); } - let mut flush_interval = tokio::time::interval(std::time::Duration::from_millis(500)); - flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Global pacer: AT MOST ONE relay frame per tick, no matter how many + // channels are active or how large the backlog is. `interval_at` starts + // the first tick a full period out, so a pre-loaded snapshot (up to the + // 1,000-event replay buffer on reconnect) cannot burst at t=0 — the old + // pacer's explicit "no initial burst" property, restored. + let mut publish_tick = tokio::time::interval_at( + tokio::time::Instant::now() + OBSERVER_PUBLISH_TICK, + OBSERVER_PUBLISH_TICK, + ); + publish_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut closed = false; loop { tokio::select! { - result = rx.recv() => { + result = rx.recv(), if !closed => { match result { Ok(event) => { // Skip live events already delivered via the snapshot @@ -475,41 +664,30 @@ async fn run_relay_observer_publisher( if event.seq <= max_snapshot_seq { continue; } - for event in coalescer.ingest(event) { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } + queue.ingest(event); } Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } tracing::warn!(dropped = count, "relay observer publisher lagged"); } Err(tokio::sync::broadcast::error::RecvError::Closed) => { - for event in coalescer.flush() { - publish_relay_observer_event( - &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, - ).await; - } - break; + // Producer gone: stop selecting on the receiver and let + // the tick arm drain what remains — still one frame per + // tick. An unpaced final drain would be a burst bypass + // around everything the pacer exists to prevent. + closed = true; } } } - _ = flush_interval.tick() => { - // Periodic flush ensures live streaming even during continuous chunk delivery. - for event in coalescer.flush() { + _ = publish_tick.tick() => { + if let Some(frame) = queue.next_frame() { publish_relay_observer_event( &publisher, &keys, &agent_pubkey_hex, - &owner_pubkey_hex, &owner_pubkey, &mut pacer, event, + &owner_pubkey_hex, &owner_pubkey, frame, ).await; } + if closed && queue.is_empty() { + break; + } } } } @@ -518,12 +696,25 @@ async fn run_relay_observer_publisher( #[derive(Default)] struct ObserverChunkCoalescer { pending: Vec, + /// Approximate serialized bytes retained in `pending` (each entry's + /// serialized skeleton at creation plus appended chunk text). Counted + /// against [`OBSERVER_PENDING_QUEUE_MAX_BYTES`] by the owning + /// [`ObserverPublishQueue`] so this buffer can never grow outside the + /// queue's byte budget (a distinct-key chunk flood parks everything here + /// and nothing would otherwise bound it). + pending_bytes: usize, } struct PendingObserverChunk { key: ObserverChunkKey, event: observer::ObserverEvent, text: String, + /// Bytes this entry contributes to `pending_bytes`. + bytes: usize, + /// GENERATED observer events merged into this entry (1 at creation, +1 + /// per absorbed chunk). Evicting the entry loses this many source events, + /// so drop accounting must charge this count, not 1. + source_events: u64, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -544,10 +735,13 @@ struct ObserverChunkKey { const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; impl ObserverChunkCoalescer { - fn ingest(&mut self, event: observer::ObserverEvent) -> Vec { + /// Returns immediately-publishable events, each paired with the number of + /// SOURCE observer events it represents (merged chunks carry the count of + /// every chunk they absorbed; passthrough events are always 1). + fn ingest(&mut self, event: observer::ObserverEvent) -> Vec<(u64, observer::ObserverEvent)> { let Some((key, text)) = observer_chunk_key_and_text(&event) else { let mut events = self.flush(); - events.push(event); + events.push((1, event)); return events; }; @@ -556,25 +750,64 @@ impl ObserverChunkCoalescer { if pending.text.len() + text.len() >= OBSERVER_CHUNK_MAX_TEXT_BYTES { let events = self.flush(); // Start a new pending entry with the current chunk. - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); return events; } pending.text.push_str(&text); + pending.bytes += text.len(); + pending.source_events += 1; + self.pending_bytes += text.len(); pending.event.seq = event.seq; pending.event.timestamp = event.timestamp; return Vec::new(); } - self.pending.push(PendingObserverChunk { key, event, text }); + self.push_pending(key, event, text); Vec::new() } - fn flush(&mut self) -> Vec { + fn push_pending( + &mut self, + key: ObserverChunkKey, + event: observer::ObserverEvent, + text: String, + ) { + // The entry RETAINS the first chunk's text twice until flush: once + // inside the serialized skeleton (`event.payload` still carries it) + // and once as the extracted `text` copy that appends grow. Both are + // real memory, so both count — charging only `serialized_len` lets a + // high-cardinality flood retain up to 2x the byte budget (each entry + // undercounts by exactly its first chunk's length). + let bytes = serialized_len(&event) + text.len(); + self.pending_bytes += bytes; + self.pending.push(PendingObserverChunk { + key, + event, + text, + bytes, + source_events: 1, + }); + } + + /// Evict the OLDEST pending entry for byte-budget enforcement. Returns + /// the number of SOURCE events the entry represented (its merged chunk + /// count), or `None` when there is nothing to drop. + fn drop_oldest(&mut self) -> Option { + if self.pending.is_empty() { + return None; + } + let removed = self.pending.remove(0); + self.pending_bytes -= removed.bytes; + Some(removed.source_events) + } + + fn flush(&mut self) -> Vec<(u64, observer::ObserverEvent)> { + self.pending_bytes = 0; self.pending .drain(..) .map(|mut pending| { set_observer_chunk_text(&mut pending.event.payload, pending.text); - pending.event + (pending.source_events, pending.event) }) .collect() } @@ -793,10 +1026,8 @@ async fn publish_relay_observer_event( agent_pubkey_hex: &str, owner_pubkey_hex: &str, owner_pubkey: &PublicKey, - pacer: &mut ObserverPublishPacer, mut event: observer::ObserverEvent, ) { - pacer.wait().await; // Trim oversized frames to fit the plaintext cap rather than letting // encrypt_observer_payload reject and drop them whole (silent telemetry loss). fit_observer_event_to_budget(&mut event); @@ -4939,12 +5170,21 @@ mod observer_snapshot_race_tests { // The run loop has exited, dropping the publisher; drain the forwarded // events until the channel closes (deterministic — no try_recv race - // with the test_pair forwarding task). + // with the test_pair forwarding task). With per-tick batching the three + // events arrive inside batch envelopes (or unwrapped when a drain held + // exactly one event); unwrap both shapes. let mut markers = Vec::new(); while let Some(event) = published_rx.recv().await { let payload: serde_json::Value = decrypt_observer_payload(&owner_keys, &event).expect("decrypt published frame"); - markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } } assert_eq!( markers, @@ -4955,36 +5195,865 @@ mod observer_snapshot_race_tests { } #[cfg(test)] -mod observer_publish_pacer_tests { +mod observer_publish_queue_tests { use super::*; + fn event(seq: u64, kind: &str, channel: Option<&str>) -> observer::ObserverEvent { + observer::ObserverEvent { + seq, + timestamp: format!("2026-04-29T04:00:{:02}Z", seq.min(59)), + kind: kind.to_string(), + agent_index: Some(0), + channel_id: channel.map(ToOwned::to_owned), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + started_at: None, + payload: serde_json::json!({ "seq": seq }), + } + } + + fn queue_of(events: Vec) -> ObserverPublishQueue { + let mut queue = ObserverPublishQueue::default(); + for event in events { + queue.ingest(event); + } + queue + } + + /// Collect every frame the queue will produce, one publish slot at a time. + fn drain_frames(queue: &mut ObserverPublishQueue) -> Vec { + let mut frames = Vec::new(); + while !queue.is_empty() { + frames.push(queue.next_frame().expect("queue not empty")); + } + frames + } + + /// Inner seqs of a frame, whether it is an envelope or an unwrapped + /// singleton. + fn frame_seqs(frame: &observer::ObserverEvent) -> Vec { + match frame.payload.get("events").and_then(|v| v.as_array()) { + Some(inner) => inner.iter().map(|e| e["seq"].as_u64().unwrap()).collect(), + None => vec![frame.seq], + } + } + + /// Retained bytes computed by WALKING the entries, independently of the + /// queue's own accumulator. Cap regressions must assert on this, not on + /// `total_pending_bytes()` — asserting the counter against itself passed + /// while the process retained ~2x the budget (Sami/Max round 3: each + /// pending coalescer entry holds the first chunk's text twice, in the + /// serialized skeleton AND the extracted `text` copy). + fn walked_retained_bytes(queue: &ObserverPublishQueue) -> usize { + let fifo: usize = queue + .events + .iter() + .map(|(_, _, event)| serialized_len(event)) + .sum(); + let coalescer: usize = queue + .coalescer + .pending + .iter() + .map(|pending| serialized_len(&pending.event) + pending.text.len()) + .sum(); + fifo + coalescer + } + + /// The walker above is itself an instrument, and every cap test asks it + /// only for `<= CAP` — a blinded walker (missing an arm, or returning 0) + /// would satisfy all of them while hiding exactly the 2x overshoot it was + /// added to catch (Sami round 5, M17-M20). Pin it two-sided: it must SEE + /// the double retention, and it must agree with the accumulator EXACTLY + /// while both stores are non-empty — neither may drift. + #[test] + fn walked_retained_bytes_agrees_with_the_accumulator_exactly() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let text = "w".repeat(7_000); + let mut queue = ObserverPublishQueue::default(); + // One pending chunk: its text lives in the serialized skeleton AND + // the extracted copy, so a walker blind to either arm reads short. + queue.ingest(chunk(1, "message-a", &text)); + assert!( + walked_retained_bytes(&queue) >= 2 * text.len(), + "the walker must SEE the first chunk's text twice \ + (skeleton + extracted copy), got {}", + walked_retained_bytes(&queue) + ); + + // Populate BOTH stores: the non-chunk event flushes message-a into + // the FIFO and queues itself; fresh pending keys (plus a same-key + // append) rebuild the coalescer side. + queue.ingest(event(2, "tool_call", Some("chan-a"))); + queue.ingest(chunk(3, "message-b", &text)); + queue.ingest(chunk(4, "message-b", &text)); + queue.ingest(chunk(5, "message-c", &text)); + assert!( + !queue.events.is_empty() && !queue.coalescer.pending.is_empty(), + "both arms must be non-empty for the agreement check to bind" + ); + assert_eq!( + queue.total_pending_bytes(), + walked_retained_bytes(&queue), + "accumulator and entry-walk must agree exactly: neither may drift" + ); + } + + /// Two or more pending events for one channel ship as a single batch + /// envelope whose payload carries every inner event in arrival order. + #[test] + fn multiple_events_ship_as_one_envelope_in_order() { + let mut queue = queue_of(vec![ + event(1, "turn_started", Some("chan-a")), + event(2, "acp_read", Some("chan-a")), + event(3, "acp_write", Some("chan-a")), + ]); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty(), "one channel, one publish slot"); + assert_eq!(frame.kind, OBSERVER_BATCH_KIND); + assert_eq!(frame.seq, 3, "envelope mirrors the last inner event"); + assert_eq!(frame_seqs(&frame), [1, 2, 3], "arrival order preserved"); + let inner = frame.payload["events"].as_array().expect("events array"); + assert_eq!(inner[1]["kind"], "acp_read", "inner events keep their kind"); + } + + /// A single pending event is published unwrapped — no envelope, so + /// consumers that predate batching still understand quiet periods. + #[test] + fn a_single_event_stays_unwrapped() { + let mut queue = queue_of(vec![event(7, "turn_started", Some("chan-a"))]); + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + assert_eq!(frame.kind, "turn_started"); + assert_eq!(frame.seq, 7); + } + + /// An empty queue yields no frame — a tick with nothing pending must not + /// publish anything. + #[test] + fn empty_queue_yields_no_frame() { + let mut queue = ObserverPublishQueue::default(); + assert!(queue.next_frame().is_none()); + assert!(queue.is_empty()); + } + + /// Frames never mix channels, and each channel's events keep their FIFO + /// order. Gathering is QUEUE-WIDE: the front event's channel collects its + /// events from anywhere in the queue (that is what keeps the drain rate + /// in bytes per slot under interleaving), so cross-channel frame order + /// MAY differ from arrival order — but a null-channel event is a barrier + /// nothing gathers across. + #[test] + fn frames_never_mix_channels_and_gather_queue_wide() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_write", Some("chan-a")), + event(3, "acp_read", Some("chan-b")), + event(4, "acp_read", Some("chan-a")), + event(5, "acp_read", None), + ]); + + let frames = drain_frames(&mut queue); + assert_eq!( + frames.len(), + 3, + "gathered: [1,2,4]@a, [3]@b, [5]@None — one frame each" + ); + for frame in &frames { + let channels: HashSet> = match frame.payload.get("events") { + Some(serde_json::Value::Array(inner)) => inner + .iter() + .map(|e| e["channelId"].as_str().map(ToOwned::to_owned)) + .collect(), + _ => std::iter::once(frame.channel_id.clone()).collect(), + }; + assert_eq!(channels.len(), 1, "a frame never mixes channels"); + } + assert_eq!( + frame_seqs(&frames[0]), + [1, 2, 4], + "chan-a gathers queue-wide, FIFO within the channel" + ); + assert_eq!(frames[0].channel_id.as_deref(), Some("chan-a")); + assert_eq!(frames[1].kind, "acp_read", "singleton stays unwrapped"); + assert_eq!(frames[1].channel_id.as_deref(), Some("chan-b")); + assert_eq!(frames[2].channel_id, None); + } + + /// A NULL-channel event is a barrier: channel events queued BEHIND it + /// must not gather into a frame ahead of it, so causally-global events + /// (`agent_panic`-class) keep their exact order against every channel. + /// The null event itself ships only its contiguous front run. + #[test] + fn null_channel_events_are_gather_barriers() { + let mut queue = queue_of(vec![ + event(1, "acp_read", Some("chan-a")), + event(2, "acp_read", Some("chan-b")), + event(3, "agent_panic", None), + event(4, "acp_write", Some("chan-a")), + ]); + + let frames = drain_frames(&mut queue); + let published: Vec> = frames.iter().map(frame_seqs).collect(); + assert_eq!( + published, + [vec![1], vec![2], vec![3], vec![4]], + "seq 4 must not gather past the null barrier into frame 1" + ); + } + + /// The drain-rate regression Sami measured: with two channels strictly + /// alternating, a front-run packer degrades to ONE event per slot + /// (~275 B/s regardless of the 64KB frame budget). Queue-wide gathering + /// must drain an interleaved backlog in ~ceil(events / per-frame-fit) + /// slots per channel, not one slot per event. + #[test] + fn interleaved_channels_drain_at_bytes_per_slot_not_events_per_slot() { + let mut events = Vec::new(); + for i in 0..100u64 { + events.push(event(2 * i + 1, "acp_read", Some("chan-a"))); + events.push(event(2 * i + 2, "acp_read", Some("chan-b"))); + } + let mut queue = queue_of(events); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() <= 4, + "200 tiny alternating events must gather into a few full frames, \ + got {} (front-run packing would need 200 slots)", + frames.len() + ); + for frame in &frames { + assert!(serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN); + } + // Within each channel, FIFO order survives the gather. + let mut seqs_a = Vec::new(); + let mut seqs_b = Vec::new(); + for frame in &frames { + match frame.channel_id.as_deref() { + Some("chan-a") => seqs_a.extend(frame_seqs(frame)), + Some("chan-b") => seqs_b.extend(frame_seqs(frame)), + other => panic!("unexpected channel {other:?}"), + } + } + assert!(seqs_a.windows(2).all(|w| w[0] < w[1]), "chan-a FIFO"); + assert!(seqs_b.windows(2).all(|w| w[0] < w[1]), "chan-b FIFO"); + assert_eq!(seqs_a.len() + seqs_b.len(), 200, "nothing lost"); + } + + /// A same-channel backlog that cannot fit one 64KB frame splits across + /// SUCCESSIVE publish slots — never multiple frames from one slot — with + /// every frame under the cap and no event lost or reordered. + #[test] + fn oversized_backlogs_split_across_publish_slots_under_the_cap() { + let big_text = "x".repeat(30_000); + let mut queue = queue_of( + (1..=6) + .map(|seq| { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + e + }) + .collect(), + ); + + let frames = drain_frames(&mut queue); + assert!( + frames.len() > 1, + "six 30KB events cannot fit one 64KB frame" + ); + let mut seen = Vec::new(); + for frame in &frames { + assert!( + serialized_len(frame) <= OBSERVER_MAX_PLAINTEXT_LEN, + "every emitted frame must fit the plaintext cap" + ); + seen.extend(frame_seqs(frame)); + } + assert_eq!( + seen, + [1, 2, 3, 4, 5, 6], + "no event lost or reordered by splitting" + ); + } + + /// The queue preserves the coalescer's ordering rule: a non-chunk event + /// force-flushes pending chunk text ahead of itself, so merged chunks can + /// never leapfrog a tool call that arrived after them. + #[test] + fn non_chunk_events_flush_pending_chunks_ahead_of_themselves() { + fn chunk(seq: u64, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": text }, + }} + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + queue.ingest(chunk(1, "hello ")); + queue.ingest(chunk(2, "world")); + queue.ingest(event(3, "tool_call", Some("chan-a"))); + + let frame = queue.next_frame().expect("one frame"); + assert!(queue.is_empty()); + let inner = frame.payload["events"].as_array().expect("batch of 2"); + assert_eq!(inner.len(), 2, "two chunks coalesce into one event"); + assert_eq!( + inner[0]["payload"]["params"]["update"]["content"]["text"], "hello world", + "chunk text merged before the tool call" + ); + assert_eq!(inner[1]["kind"], "tool_call"); + assert!(inner[0]["seq"].as_u64() < inner[1]["seq"].as_u64()); + } + + /// Chunks still pending inside the coalescer (no non-chunk flushed them) + /// are picked up by the publish slot itself, not stranded. + #[test] + fn a_publish_slot_flushes_pending_coalesced_chunks() { + let mut e = event(1, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "params": { "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": "m1", + "content": { "text": "buffered" }, + }} + }); + let mut queue = ObserverPublishQueue::default(); + queue.ingest(e); + assert!(!queue.is_empty(), "pending chunk counts as queued work"); + + let frame = queue.next_frame().expect("chunk must ship"); + assert!(queue.is_empty()); + assert_eq!( + frame.payload["params"]["update"]["content"]["text"], + "buffered" + ); + } + + /// Sami's ceiling assertion: when sustained input outruns the one-frame + /// drain budget for longer than the queue's byte budget, the OLDEST events + /// drop with accounting — never silently — and everything that survives + /// publishes in order with nothing else lost. + #[test] + fn over_budget_floods_drop_oldest_with_accounting() { + let big_text = "y".repeat(10_000); + let total = 500usize; // ~5MB of ~10KB events > 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total as u64 { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ "seq": seq, "text": big_text }); + queue.ingest(e); + } + + assert!( + queue.dropped_events > 0, + "a 5MB backlog must overflow the 4MiB budget" + ); + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + let expected: Vec = (queue.dropped_events + 1..=total as u64).collect(); + assert_eq!( + published, expected, + "exactly the oldest `dropped_events` events are missing; the rest \ + publish in order" + ); + assert_eq!( + published.len() as u64 + queue.dropped_events, + total as u64, + "accounting: published + dropped == ingested" + ); + } + + /// Max's coalescer-bypass regression: a flood of chunks with DISTINCT + /// messageIds never flushes on its own, so every chunk sits in the + /// coalescer's pending buffer. TRUE retained bytes — walked from the + /// entries, never the queue's own accumulator — MUST respect the byte + /// budget with event-level drop accounting. Pre-fix this retained ~25MB + /// against the 4 MiB cap with `pending_bytes == 0` and zero drops; the + /// round-3 refinement (Sami/Max) caught the accumulator itself reading + /// under cap while true retention was 1.99x over. + #[test] + fn distinct_key_chunk_floods_are_bounded_by_the_byte_budget() { + let big_text = "z".repeat(50_000); + let total = 500u64; // ~25MB pending chunk text vs a 4MiB budget + let mut queue = ObserverPublishQueue::default(); + for seq in 1..=total { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": format!("message-{seq}"), + "content": { "type": "text", "text": big_text }, + }, + }, + }); + queue.ingest(e); + } + + let walked = walked_retained_bytes(&queue); + assert!( + walked <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "TRUE retained bytes (walked from entries) must respect the cap, \ + got {walked}" + ); + assert!( + queue.total_pending_bytes() >= walked, + "the accumulator must never under-count true retention \ + (accumulator {} < walked {walked})", + queue.total_pending_bytes() + ); + assert!( + queue.dropped_events > 0, + "a ~25MB distinct-key chunk flood must record drops" + ); + // Event-level accounting: everything that survives publishes, and + // survivors + dropped == ingested. + let frames = drain_frames(&mut queue); + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + total, + "accounting: published + dropped == ingested" + ); + // The survivors are the NEWEST events (drop-oldest). + let last_frame_seqs = frame_seqs(frames.last().expect("frames")); + assert_eq!(*last_frame_seqs.last().expect("seqs"), total); + } + + /// Max's merged-chunk accounting regression: one coalescer entry can + /// represent MANY generated observer events (same-messageId chunks merge + /// in place), so evicting it must charge every merged source event to + /// `dropped_events`, not 1 per retained entry. Pre-fix, evicting an entry + /// that merged 50 chunks recorded `dropped_events == 1` and 49 generated + /// events vanished from the accounting. + #[test] + fn evicting_a_merged_chunk_entry_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks under ONE messageId merge into a single pending + // coalescer entry — the oldest item anywhere in the queue. + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // Flood with distinct-key 50KB chunks until the byte budget evicts + // the oldest entries — the merged entry goes first. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the merged entry (globally oldest) must have been evicted" + ); + // Every survivor is an unmerged distinct-key chunk (1 source each), + // so source-event accounting must close exactly: the merged entry's + // eviction charges all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + assert_eq!( + survived + queue.dropped_events, + merged_sources + flood, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Sami's M13 / Max's forced-flush probe: the OTHER eviction arm. A + /// merged entry FLUSHED into the publish FIFO (by a non-chunk event) must + /// still charge every absorbed source on eviction — the FIFO stores the + /// per-entry count precisely so the ledger survives flush. The + /// coalescer-side regression above never exercises this arm; mutating the + /// FIFO eviction to `dropped += 1` survived all 687 tests until this one. + #[test] + fn evicting_a_flushed_merged_entry_from_the_fifo_accounts_every_source_event() { + fn chunk(seq: u64, message_id: &str, text: &str) -> observer::ObserverEvent { + let mut e = event(seq, "acp_read", Some("chan-a")); + e.payload = serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": "agent_message_chunk", + "messageId": message_id, + "content": { "type": "text", "text": text }, + }, + }, + }); + e + } + + let mut queue = ObserverPublishQueue::default(); + // 50 × 1KB chunks merge under one messageId in the coalescer… + let merged_text = "m".repeat(1_000); + let merged_sources = 50u64; + for seq in 1..=merged_sources { + queue.ingest(chunk(seq, "message-merged", &merged_text)); + } + // …then a non-chunk event force-flushes the merged entry into the + // publish FIFO. From here eviction happens on the FIFO arm. + queue.ingest(event(merged_sources + 1, "tool_call", Some("chan-a"))); + assert!( + queue.coalescer.pending.is_empty(), + "the non-chunk event must have flushed the merged entry" + ); + assert_eq!( + queue.events.front().expect("flushed entry queued").1, + merged_sources, + "the FIFO front must carry the merged source count" + ); + + // Distinct-key flood forces byte-budget eviction of the FIFO front. + let flood_text = "f".repeat(50_000); + let flood = 100u64; + for seq in 1..=flood { + queue.ingest(chunk( + merged_sources + 1 + seq, + &format!("message-{seq}"), + &flood_text, + )); + } + + assert!( + walked_retained_bytes(&queue) <= OBSERVER_PENDING_QUEUE_MAX_BYTES, + "eviction must restore the byte budget (entry-walked), got {}", + walked_retained_bytes(&queue) + ); + let frames = drain_frames(&mut queue); + assert!( + !frames + .iter() + .flat_map(frame_seqs) + .any(|seq| seq <= merged_sources), + "the flushed merged entry (globally oldest) must have been evicted" + ); + // Ledger in source units: survivors are unmerged (1 source each), the + // evicted merged FIFO entry must charge all 50 sources. + let survived: u64 = frames.iter().map(|f| frame_seqs(f).len() as u64).sum(); + let ingested = merged_sources + 1 + flood; + assert_eq!( + survived + queue.dropped_events, + ingested, + "accounting: published sources + dropped sources == ingested" + ); + } + + /// Under the byte budget the queue is lossless: every ingested event + /// publishes exactly once. + #[test] + fn under_budget_backlogs_are_lossless() { + let mut queue = queue_of( + (1..=200) + .map(|seq| event(seq, "acp_read", Some("chan-a"))) + .collect(), + ); + let frames = drain_frames(&mut queue); + let published: Vec = frames.iter().flat_map(frame_seqs).collect(); + assert_eq!(published, (1..=200).collect::>()); + assert_eq!(queue.dropped_events, 0); + } +} + +#[cfg(test)] +mod observer_publish_cadence_tests { + use super::*; + use nostr::Keys; + + /// Let every spawned task (publisher loop, test_pair forwarder) run to + /// quiescence WITHOUT advancing paused time. `yield_now` keeps this task + /// runnable, so tokio's auto-advance never fires here — time only moves + /// when the test says so. + async fn settle() { + for _ in 0..64 { + tokio::task::yield_now().await; + } + } + + fn recv_all(rx: &mut tokio::sync::mpsc::Receiver) -> Vec { + let mut out = Vec::new(); + while let Ok(event) = rx.try_recv() { + out.push(event); + } + out + } + + fn count_inner(owner: &Keys, event: &nostr::Event) -> usize { + let payload: serde_json::Value = + decrypt_observer_payload(owner, event).expect("decrypt frame"); + match payload["payload"]["events"].as_array() { + Some(inner) => inner.len(), + None => 1, + } + } + + fn emit_on(observer: &observer::ObserverHandle, channel: Option, marker: &str) { + observer.emit( + "test_event", + None, + &observer::context_for(channel, None, None), + serde_json::json!({ "marker": marker }), + ); + } + + /// THE regression Max demanded: with a backlog needing multiple frames + /// (two channels — a frame never mixes channels, so the backlog takes two + /// publish slots), no frame publishes before its tick. Startup publishes + /// NOTHING at t=0 (Sami's Finding 1: a full replay buffer must not burst + /// on reconnect), frame 1 arrives at +1s, frame 2 no earlier than +2s. #[tokio::test(start_paused = true)] - async fn starts_without_a_burst_and_spaces_frames() { - let started = tokio::time::Instant::now(); - let mut pacer = ObserverPublishPacer::new(); + async fn one_frame_per_second_and_no_startup_burst() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Interleave channels so the backlog cannot fit one frame: each run + // boundary forces a new publish slot. + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + assert_eq!(snapshot.len(), 3, "all three preloaded in the snapshot"); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + // t=0: nothing may publish, no matter how full the snapshot was. + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "startup must not burst at t=0" + ); - pacer.wait().await; - let first = tokio::time::Instant::now(); - pacer.wait().await; - let second = tokio::time::Instant::now(); + // t=0.999s: still nothing. + tokio::time::advance(Duration::from_millis(999)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "no frame may publish before the first tick" + ); - assert_eq!(first.duration_since(started), OBSERVER_PUBLISH_INTERVAL); - assert_eq!(second.duration_since(first), OBSERVER_PUBLISH_INTERVAL); + // t=1s: exactly ONE frame — chan-a gathered queue-wide, so a1 AND a2 + // ride the first slot together. + tokio::time::advance(Duration::from_millis(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "tick 1 publishes exactly one frame"); + assert_eq!(count_inner(&owner_keys, &frames[0]), 2, "a1 + a2 gathered"); + + // t=1.5s: between ticks, nothing. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "frame 2 must wait for tick 2" + ); + + // t=2s: the chan-b frame drains on its own tick. + tokio::time::advance(Duration::from_millis(500)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "tick 2: one frame"); + + // Backlog drained; a quiet tick publishes nothing. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 0, "quiet tick is quiet"); + + task.abort(); } + /// Shutdown is NOT a burst bypass: when the producer closes with a + /// backlog, the remaining frames still publish one per tick, and the loop + /// exits only after the queue is empty — paced, lossless, in order. #[tokio::test(start_paused = true)] - async fn limits_frames_in_each_rolling_minute() { - let mut pacer = ObserverPublishPacer::new(); - pacer.wait().await; - let first = tokio::time::Instant::now(); - for _ in 1..OBSERVER_PUBLISH_LIMIT_PER_MINUTE { - pacer.wait().await; + async fn shutdown_drain_is_paced_and_lossless() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + let chan_a = uuid::Uuid::new_v4(); + let chan_b = uuid::Uuid::new_v4(); + emit_on(&observer, Some(chan_a), "a1"); + emit_on(&observer, Some(chan_b), "b1"); + emit_on(&observer, Some(chan_a), "a2"); + + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + // Close the broadcast channel immediately: the entire drain happens + // in "shutdown" mode. + drop(observer); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 0, + "shutdown drain must not burst at t=0" + ); + + let mut markers = Vec::new(); + for tick in 1..=2 { + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + let frames = recv_all(&mut published_rx); + assert_eq!(frames.len(), 1, "shutdown tick {tick}: exactly one frame"); + let payload: serde_json::Value = + decrypt_observer_payload(&owner_keys, &frames[0]).expect("decrypt"); + match payload["payload"]["events"].as_array() { + Some(inner) => markers.extend( + inner + .iter() + .map(|e| e["payload"]["marker"].as_str().unwrap().to_string()), + ), + None => markers.push(payload["payload"]["marker"].as_str().unwrap().to_string()), + } } + // Gather-packing: chan-a (a1+a2) ships tick 1, chan-b tick 2. + assert_eq!(markers, ["a1", "a2", "b1"], "paced drain loses nothing"); + + // Queue empty + closed: the loop must have exited on its own. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert!(task.is_finished(), "publisher exits after paced drain"); + } + + /// Pins `MissedTickBehavior::Skip` (Sami's M6 mutant): when the publisher + /// misses ticks — relay backpressure can stall the tick arm past several + /// deadlines, since `publish_event` awaits a bounded mpsc — the interval + /// must fire ONE catch-up tick and realign, not fire once per missed + /// deadline. With `Burst`, a 10s stall against a multi-frame backlog + /// would replay all 10 missed ticks back-to-back: an unpaced burst that + /// bypasses exactly what the pacer exists to prevent. + #[tokio::test(start_paused = true)] + async fn missed_ticks_skip_instead_of_bursting() { + let observer = observer::ObserverHandle::in_process(); + let agent_keys = Keys::generate(); + let owner_keys = Keys::generate(); + let (publisher, mut published_rx) = RelayEventPublisher::test_pair(); + + // Three channels => three frames pending (a frame never mixes + // channels), so a bursting interval would have work for every + // spurious catch-up tick. + for chan in 0..3 { + emit_on(&observer, Some(uuid::Uuid::new_v4()), &format!("c{chan}")); + } + let rx = observer.subscribe(); + let snapshot = observer.snapshot(); + + let task = tokio::spawn(run_relay_observer_publisher( + snapshot, + rx, + publisher, + agent_keys.clone(), + agent_keys.public_key().to_hex(), + owner_keys.public_key().to_hex(), + owner_keys.public_key(), + )); + settle().await; - pacer.wait().await; - let ninety_first = tokio::time::Instant::now(); + // Jump 10 seconds in ONE advance — the loop was never polled in + // between, exactly like a stall across 10 deadlines. + tokio::time::advance(Duration::from_secs(10)).await; + settle().await; + assert_eq!( + recv_all(&mut published_rx).len(), + 1, + "Skip: one catch-up frame after a stall — Burst would publish \ + one per missed deadline" + ); + + // The interval realigned: the remaining backlog stays paced. + tokio::time::advance(Duration::from_secs(1)).await; + settle().await; + assert_eq!(recv_all(&mut published_rx).len(), 1, "paced after realign"); - assert_eq!(ninety_first.duration_since(first), Duration::from_secs(60)); + task.abort(); } } @@ -5058,9 +6127,14 @@ mod observer_chunk_coalescer_tests { let events = coalescer.ingest(non_chunk_event(3)); assert_eq!(events.len(), 2); - assert_eq!(events[0].seq, 2); - assert_eq!(chunk_text(&events[0]), "hello world"); - assert_eq!(events[1].kind, "turn_started"); + assert_eq!(events[0].1.seq, 2); + assert_eq!(chunk_text(&events[0].1), "hello world"); + assert_eq!( + events[0].0, 2, + "a merged entry reports every source chunk it absorbed" + ); + assert_eq!(events[1].1.kind, "turn_started"); + assert_eq!(events[1].0, 1); } #[test] @@ -5081,8 +6155,8 @@ mod observer_chunk_coalescer_tests { let events = coalescer.flush(); assert_eq!(events.len(), 2); - assert_eq!(chunk_text(&events[0]), "answer"); - assert_eq!(chunk_text(&events[1]), "thinking"); + assert_eq!(chunk_text(&events[0].1), "answer"); + assert_eq!(chunk_text(&events[1].1), "thinking"); } } @@ -5125,7 +6199,7 @@ mod build_mcp_servers_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, respond_to_allowlist: std::collections::HashSet::new(), allowed_respond_to: vec![], @@ -5347,7 +6421,7 @@ mod error_outcome_emission_tests { memory_enabled: false, model: None, session_title: None, - permission_mode: config::PermissionMode::BypassPermissions, + permission_mode: config::PermissionMode::DontAsk, respond_to: config::RespondTo::Anyone, respond_to_allowlist: HashSet::new(), allowed_respond_to: vec![], diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index ddc0330d9f..8430307d9c 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1017,7 +1017,7 @@ async fn create_session_and_apply_model( // Apply permission mode if not the agent's built-in default AND the agent // advertises the requested mode in session/new. Agents that don't support // the mode (e.g., goose crashes on unrecognized set_config_option values) - // are safely skipped — the harness auto-approves via handle_permission_request. + // are safely skipped — the harness rejects interactive permission requests. if !ctx.permission_mode.is_default() && agent_supports_mode(&resp.raw, ctx.permission_mode.as_wire_str()) { @@ -1130,11 +1130,7 @@ async fn apply_model_switch( Ok(()) } -/// Set the session permission mode via `session/set_config_option`. -/// -/// Non-fatal for most errors: logs and proceeds. The agent falls back -/// to its default permission mode (`"default"`), which still works via -/// Check if the agent's `session/new` response advertises a given mode ID +/// Check whether the agent's `session/new` response advertises a given mode ID /// in `result.modes.availableModes[].id`. Returns `false` if the modes /// field is absent or the mode isn't listed. fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) -> bool { @@ -1150,7 +1146,11 @@ fn agent_supports_mode(session_new_result: &serde_json::Value, mode_wire: &str) .unwrap_or(false) } -/// per-tool auto-approval in `handle_permission_request`. +/// Set the session permission mode via `session/set_config_option`. +/// +/// Non-fatal for most errors: logs and proceeds. The agent falls back to its +/// default mode, and any interactive permission request is rejected by +/// `handle_permission_request`. /// /// **Fatal exception:** if the agent process exits (e.g., goose crashes on /// unrecognized methods), returns `Err(AgentExited)` so the caller can respawn. @@ -1190,7 +1190,7 @@ async fn apply_permission_mode( Ok(Err(e)) => { tracing::warn!( target: "pool::permission", - "failed to set permission mode {wire:?}: {e} — falling back to per-tool auto-approval" + "failed to set permission mode {wire:?}: {e} — falling back to per-tool rejection" ); } Err(_) => { diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index aea5cee077..2cbb82411f 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -106,9 +106,12 @@ const REQ_PACING_INTERVAL: Duration = Duration::from_millis(125); /// blocked for more than one REQ's worth of I/O between drain ticks. const DRAIN_BUDGET_PER_ITER: usize = 1; /// Maximum observer telemetry frames parked while the rate-limit gate is armed -/// (or the socket is down). The upstream pacer feeds at most ~6 frames/s, so -/// this covers ~40 s of gating; beyond that the oldest frames are dropped with -/// visible accounting (`gated_observer_dropped`). +/// (or the socket is down). The upstream publisher ships at most ONE batched +/// frame per second GLOBALLY (one publish slot per tick, regardless of how +/// many channels are active), so this covers ~4 minutes of gating; beyond that +/// the oldest frames are dropped with visible accounting +/// (`gated_observer_dropped`). Note each dropped frame may carry a whole batch +/// of events, so event-level loss is larger than the frame count. const GATED_OBSERVER_QUEUE_CAP: usize = 256; use std::time::Instant; diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index ff87a33a1a..054c334405 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -6,7 +6,7 @@ use tokio::task::JoinSet; use crate::builtin; use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; -use crate::handoff::HandoffOutcome; +use crate::handoff::{ContextRecovery, HandoffOutcome}; use crate::hints::SkillEntry; use crate::llm::Llm; use crate::mcp::McpRegistry; @@ -21,6 +21,34 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +const UNSUPPORTED_IMAGE_TOOL_MESSAGE: &str = "The current model does not support image input. The image was removed from conversation history so this turn can continue. Use a text-based inspection tool or ask the user for a textual description instead."; + +/// Remove image blocks that the provider has explicitly rejected while keeping +/// their surrounding tool result (and therefore the tool-call/result pairing) +/// intact. Returns the number of images removed; zero means the provider error +/// cannot be safely recovered by mutating history. +fn replace_unsupported_images(history: &mut [HistoryItem]) -> usize { + let mut replaced = 0; + for item in history { + let HistoryItem::ToolResult(result) = item else { + continue; + }; + let before = result.content.len(); + result + .content + .retain(|content| !matches!(content, ToolResultContent::Image { .. })); + let removed = before - result.content.len(); + if removed > 0 { + replaced += removed; + result.is_error = true; + result.content.push(ToolResultContent::Text( + UNSUPPORTED_IMAGE_TOOL_MESSAGE.to_string(), + )); + } + } + replaced +} + /// Maximum reply reminders emitted per prompt when `require_reply` is on. /// /// After this many, the turn is allowed to end whether or not anything was @@ -201,6 +229,14 @@ impl RunCtx<'_> { *self.turn_output_tokens = None; *self.turn_cached_input_tokens = None; *self.turn_total_state = TurnTotalState::Unseen; + // Per-turn handoff-attempt counter. Scoped here (not persisted in the + // session) so `BUZZ_AGENT_MAX_HANDOFFS` bounds compactions per + // `session/prompt` turn rather than per session lifetime. A + // long-lived session legitimately needs unbounded handoffs across + // prompts; the cap only exists to stop runaway within a single turn. + // The session-cumulative `handoff_count` (used in log lines) is not + // reset: it reflects total compactions since session start. + let mut handoff_attempts: usize = 0; let mut round = 0u32; // Per-prompt `_Stop` objection count. Bounded per prompt (not per @@ -215,6 +251,10 @@ impl RunCtx<'_> { // successful publish. See `is_buzz_reply_call`. let mut buzz_reply_call_seen = false; let mut reply_nags = 0u32; + // Per-`run()` reactive context-recovery budget. Per-turn, not + // per-session: a fresh prompt deserves a fresh chance to recover, and + // `max_rounds` defaults to 0 (unbounded) so it cannot bound this. + let mut context_recoveries = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -227,7 +267,7 @@ impl RunCtx<'_> { // its next request — the turn continues, it is not restarted. Drain // non-blocking; an empty queue is the common case. self.drain_steers(); - match self.maybe_handoff().await { + match self.maybe_handoff(&mut handoff_attempts).await { HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled), // Context was just reset — the prior request's token count no // longer describes the (now much smaller) history. Clear both @@ -249,10 +289,10 @@ impl RunCtx<'_> { tools.push(builtin::load_skill_def()); } round = round.saturating_add(1); - let response = tokio::select! { + let response_result = tokio::select! { biased; _ = self.cancel.changed() => return Ok(StopReason::Cancelled), - r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r?, + r = self.llm.complete(self.cfg, self.system_prompt, self.history, &tools, self.effective_model) => r, _ = async { // Keepalive ticker: emit a lightweight session update every 30s // while waiting on the LLM provider. This resets the ACP harness @@ -275,7 +315,78 @@ impl RunCtx<'_> { } } => unreachable!(), }; - + let response = match response_result { + Ok(response) => response, + Err(AgentError::UnsupportedImageInput(detail)) => { + let removed = replace_unsupported_images(self.history); + if removed == 0 { + return Err(AgentError::UnsupportedImageInput(detail)); + } + tracing::warn!( + model = self.effective_model, + removed_images = removed, + "provider rejected image input; removed images from history and continuing turn" + ); + continue; + } + // Reactive context recovery. A context-window 400 is the only + // ground-truth signal that history must shrink, and it arrives + // exactly when the proactive gate cannot act: a failed request + // reports no usage, so `last_request_input_tokens` stays frozen + // at the last SUCCESSFUL (sub-threshold) reading and + // `should_handoff()` returns false forever. Without this arm the + // error propagates out of `run()`, the in-memory session keeps + // the same oversized history, and every later prompt in that + // session fails the same way — a stick that persists across + // turns for the life of the session. (Restarting the agent DOES + // clear it: history lives only in the in-memory session map, so + // a restart is the manual workaround, not an exception to it.) + // + // Retried in-loop rather than returned so the recovered context + // continues the turn the user is waiting on. + Err(AgentError::LlmContextExceeded(e)) => { + match self + .recover_from_context_overflow(&mut context_recoveries) + .await + { + ContextRecovery::Recovered => { + // Refund the round the rejected request consumed. + // `round` is incremented before `complete()`, so + // without this a finite `max_rounds` is spent by a + // request the provider refused to serve: the loop + // would re-enter, hit the cap at the top, and return + // `MaxTurnRequests` having destroyed history and + // never asked the model again — a worse outcome than + // the error it replaced. + // + // This cannot become an unbounded amnesty: refunds + // happen only on a *successful* recovery, and + // recoveries are independently capped by + // `MAX_CONTEXT_RECOVERIES_PER_RUN`, so at most that + // many rounds can ever be refunded in one turn. An + // ordinary round is never refunded. + round = round.saturating_sub(1); + // Same reset as the proactive path (see + // `HandoffOutcome::Performed` above): the frozen + // token reading describes history that no longer + // exists. Clearing it is what lets the gate work + // again on later rounds. + *self.last_request_input_tokens = None; + *self.last_request_history_bytes = None; + continue; + } + ContextRecovery::Cancelled => return Ok(StopReason::Cancelled), + // No rescue left. Surface the provider's own error + // rather than a synthetic one: it names the model and + // the offending sizes, and a visible failure is the + // point — the alternative is retrying forever. + ContextRecovery::Exhausted => { + return Err(AgentError::LlmContextExceeded(e)) + } + } + } + Err(error) => return Err(error), + }; // Record provider-reported input usage so the next loop iteration's // handoff gate can compare it against the token budget. We capture // it together with the history byte size AT THIS MOMENT — which is @@ -945,6 +1056,66 @@ mod tests { use super::*; use serde_json::json; + /// `truncate_history` cannot serve as the context-window fallback: it is + /// measured in BYTES (`max_history_bytes`, default 16 MiB, a request-body + /// limiter) while the thing the fallback must defend is a TOKEN window + /// (`max_context_tokens`, default 200k). A history large enough to blow a + /// 200k-token window is nowhere near 16 MiB, so at the default budget the + /// fallback evicts nothing at all — which is why the `Skipped -> + /// truncate_history` path left the agent permanently stuck and the reactive + /// ladder had to be built instead. + /// + /// The negative assertion is paired with a positive control (same helper, + /// same fixture, budget set to the window instead) so that "evicted + /// nothing" is a real observation about the unit mismatch rather than a + /// blind probe that could never evict. + #[test] + fn truncate_history_is_a_noop_at_context_window_scale() { + // ~800 KB of history. At any real bytes/token density (densest real + // content is ~1.4 B/tok, typical prose ~3-4) this is >= 200k tokens, + // i.e. already over a 200k window. + let mut history: Vec = Vec::new(); + for i in 0..400 { + history.push(HistoryItem::User(format!("q{i} {}", "x".repeat(1000)))); + history.push(HistoryItem::Assistant { + text: format!("a{i} {}", "y".repeat(1000)), + tool_calls: vec![], + reasoning_details: None, + }); + } + let total: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + let pressure: usize = history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + assert!( + total > 800_000, + "fixture must be big enough to exceed a 200k-token window, got {total}" + ); + + // NEGATIVE: the real configured default budget. + let default_budget = 16 * 1024 * 1024; + let mut under_default = history.clone(); + truncate_history(&mut under_default, default_budget); + assert_eq!( + under_default.len(), + history.len(), + "16 MiB byte budget evicted nothing from a {total}-byte history \ + (pressure {pressure}) that already exceeds a 200k-token window" + ); + + // POSITIVE CONTROL: same helper, same fixture, budget set to the + // window instead. If this also evicted nothing the assertion above + // would prove nothing about the unit mismatch -- it would just mean + // the probe is blind. + let mut under_window = history.clone(); + truncate_history(&mut under_window, 200_000); + assert!( + under_window.len() < history.len(), + "positive control must evict: probe is blind otherwise" + ); + } + /// The shapes the guard must recognize as a publish attempt. Callers apply /// the registry checks first; these cover the name suffix and command text. #[test] @@ -1075,6 +1246,47 @@ mod tests { assert!(total_after <= max_bytes); } + #[test] + fn unsupported_images_become_recoverable_tool_errors() { + let mut history = vec![ + HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: "call-image".into(), + name: "dev__view_image".into(), + arguments: json!({ "source": "spec.png" }), + provider_extra: Default::default(), + }], + reasoning_details: None, + }, + HistoryItem::ToolResult(ToolResult { + provider_id: "call-image".into(), + content: vec![ + ToolResultContent::Text("10x10 image from spec.png".into()), + ToolResultContent::Image { + data: "aW1n".into(), + mime_type: "image/png".into(), + }, + ], + is_error: false, + }), + ]; + + assert_eq!(replace_unsupported_images(&mut history), 1); + let HistoryItem::ToolResult(result) = &history[1] else { + panic!("tool result must stay paired with the assistant tool call"); + }; + assert_eq!(result.provider_id, "call-image"); + assert!(result.is_error); + assert!(result + .content + .iter() + .all(|content| !matches!(content, ToolResultContent::Image { .. }))); + assert!(result.text().contains("does not support image input")); + assert!(result.text().contains("10x10 image from spec.png")); + assert_eq!(replace_unsupported_images(&mut history), 0); + } + #[test] fn truncate_history_noop_when_under_budget() { let mut history = vec![ diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index afbda5379d..439e49f4e5 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -657,6 +657,21 @@ pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024; pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; +/// Maximum reactive context-recovery attempts per `run()`. A provider +/// context-window 400 is recoverable — shrink history and retry — but the +/// retry must be bounded: `max_rounds` defaults to `0` (unbounded), so without +/// its own budget a request that stays oversized after every rescue would +/// retry forever. On exhaustion the error surfaces to the caller, which is a +/// visible failure rather than a silent infinite rescue. +pub const MAX_CONTEXT_RECOVERIES_PER_RUN: u32 = 3; + +/// Floor for the reactive handoff's history-prompt budget, in bytes. Each +/// recovery attempt halves the budget so the rescue summarize call can escape +/// an overstated `max_context_tokens`, but halving must terminate: below this +/// the prompt can no longer carry a useful summary, so the recovery gives up +/// and surfaces the error instead of issuing ever-smaller doomed requests. +pub const HANDOFF_MIN_PROMPT_BUDGET_BYTES: usize = 4 * 1024; + const DEFAULT_SYSTEM_PROMPT: &str = "You are buzz-agent. Use the provided tools to act. Tool calls are your only output."; @@ -714,6 +729,11 @@ pub struct Config { /// operators lower/raise it for other models. Set via /// `BUZZ_AGENT_MAX_CONTEXT_TOKENS`. pub max_context_tokens: u64, + /// Maximum context-handoff attempts permitted within a single + /// `session/prompt` turn. Caps runaway compaction loops inside one turn; + /// does NOT limit handoffs across a session's lifetime — a long-lived + /// session can compact on every successive turn without hitting this bound. + /// Set via `BUZZ_AGENT_MAX_HANDOFFS`. Default 10. pub max_handoffs: usize, pub max_parallel_tools: usize, pub hook_timeout: Duration, diff --git a/crates/buzz-agent/src/handoff.rs b/crates/buzz-agent/src/handoff.rs index 3b0feefecf..5fdbc3079d 100644 --- a/crates/buzz-agent/src/handoff.rs +++ b/crates/buzz-agent/src/handoff.rs @@ -1,6 +1,7 @@ use crate::agent::RunCtx; use crate::config::{ - HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES, + HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_MIN_PROMPT_BUDGET_BYTES, + HANDOFF_ORIGINAL_TASK_MAX_BYTES, MAX_CONTEXT_RECOVERIES_PER_RUN, }; use crate::types::HistoryItem; @@ -22,24 +23,147 @@ pub(crate) enum HandoffOutcome { Cancelled, } +/// Result of the reactive context-recovery ladder. +pub(crate) enum ContextRecovery { + /// History was reset; the caller should retry the request. + Recovered, + /// Cancelled mid-recovery. + Cancelled, + /// No rescue remains — the caller must surface the provider error. Either + /// the per-`run()` budget is spent or the prompt budget fell below the + /// floor where a summary can still be useful. + Exhausted, +} + const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \ turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \ you accomplished, key decisions made, what remains, and one concrete next step. Output plain \ text only — no tool calls, no JSON. Stay under 8192 tokens."; impl RunCtx<'_> { - pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome { + pub(crate) async fn maybe_handoff(&mut self, handoff_attempts: &mut usize) -> HandoffOutcome { if !self.should_handoff() { return HandoffOutcome::Skipped; } - if *self.handoff_count >= self.cfg.max_handoffs { - tracing::info!( - "handoff cap reached ({}); using truncation", - self.cfg.max_handoffs + if *handoff_attempts >= self.cfg.max_handoffs { + let projected = self.projected_handoff_input_tokens(); + let threshold = + token_threshold(self.cfg.max_context_tokens, self.cfg.max_output_tokens); + tracing::warn!( + session_id = self.session_id, + reason = "preflight", + handoff_attempts = *handoff_attempts, + max_handoffs = self.cfg.max_handoffs, + projected_tokens = projected, + threshold_tokens = threshold, + "handoff cap reached; using truncation", ); return HandoffOutcome::Skipped; } - let prompt = self.build_handoff_prompt(); + // Consume one attempt slot before calling handoff(). This ensures + // that empty-summary, summarize-error, and cancellation outcomes all + // burn budget — not just successful compactions — so the cap cannot + // be bypassed by a flaky summarizer. + *handoff_attempts += 1; + self.handoff(None).await + } + + /// Handoff forced by a provider context-window rejection, bypassing both + /// gates in [`Self::maybe_handoff`]. + /// + /// The gates exist to *predict* overflow; a 400 naming a context-length + /// overflow is overflow already observed, so neither prediction applies. + /// `should_handoff()` reads a token count frozen at the last SUCCESSFUL + /// request (a failed request reports no usage), so it is under threshold by + /// construction — that frozen reading is the permanent stick. And + /// `max_handoffs` is a cost cap whose only alternative here is a request + /// that cannot succeed. + /// + /// `history_budget_bytes` is explicit rather than derived from + /// `cfg.max_context_tokens`: that window is the quantity the provider just + /// contradicted, so the recovery ladder must not be computed from it. + pub(crate) async fn forced_handoff(&mut self, history_budget_bytes: usize) -> HandoffOutcome { + tracing::warn!( + "provider reported context overflow; forcing handoff (history budget {history_budget_bytes} bytes)" + ); + self.handoff(Some(history_budget_bytes)).await + } + + /// The reactive context-recovery ladder, run after the provider rejected a + /// request with a context-window 400. + /// + /// `attempts` is the caller's per-`run()` recovery counter, advanced here as + /// rungs are consumed. The caller owns it so the budget spans every + /// context-400 in the turn, not just the rungs of one ladder. + /// + /// The shrink schedule is anchored on the history that was just *observed* + /// to be too large, halving from there — not on `cfg.max_context_tokens`, + /// which the provider just contradicted and which may be overstated by an + /// unknown factor. Halving needs no calibration: by the third rung it is at + /// 1/8 of the rejected size. + /// + /// Loops rather than returning after one rung because the summarize call + /// travels the same provider path and can be rejected for the same reason. + /// Treating that as unrecoverable would reproduce the very stick this fixes: + /// the next rung halves the summarizer's own prompt, which is the only way + /// out. + /// + /// Gives up when the next budget would fall below + /// [`HANDOFF_MIN_PROMPT_BUDGET_BYTES`]. That can happen on the FIRST rung + /// when history is already small — correct, not premature: if a few KiB of + /// history still overflows the window, the overflow is dominated by what a + /// handoff cannot shrink (system prompt, tool schemas, the live user + /// prompt), so further halving would only issue smaller doomed requests in + /// place of a clear error. + pub(crate) async fn recover_from_context_overflow( + &mut self, + attempts: &mut u32, + ) -> ContextRecovery { + let rejected_bytes: usize = self + .history + .iter() + .map(HistoryItem::context_pressure_bytes) + .sum(); + loop { + if *attempts >= MAX_CONTEXT_RECOVERIES_PER_RUN { + tracing::error!( + "context recovery budget spent ({MAX_CONTEXT_RECOVERIES_PER_RUN} attempts this turn); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + // Shift by `attempts + 1`: the first rung already halves, since + // rebuilding the rejected size would just fail again. + let shift = (*attempts + 1).min(usize::BITS - 1); + let budget = rejected_bytes >> shift; + *attempts += 1; + if budget < HANDOFF_MIN_PROMPT_BUDGET_BYTES { + tracing::error!( + "context recovery would shrink the handoff prompt to {budget} bytes, below \ + the {HANDOFF_MIN_PROMPT_BUDGET_BYTES}-byte floor (history {rejected_bytes} \ + bytes); surfacing provider error" + ); + return ContextRecovery::Exhausted; + } + match self.forced_handoff(budget).await { + HandoffOutcome::Performed => return ContextRecovery::Recovered, + HandoffOutcome::Cancelled => return ContextRecovery::Cancelled, + // Summarizer errored or returned nothing — possibly because its + // own prompt overflowed. Truncation is not a usable fallback + // (it sizes against the request-body budget, not context + // pressure), so take the next rung with a smaller prompt. + HandoffOutcome::Skipped => { + tracing::warn!( + "forced handoff at {budget} bytes did not run; shrinking further" + ) + } + } + } + } + + /// The handoff mechanism itself: summarize, reset, re-seat the live prompt. + /// Holds no gate — callers decide whether a handoff is warranted. + async fn handoff(&mut self, history_budget_bytes: Option) -> HandoffOutcome { + let prompt = self.build_handoff_prompt(history_budget_bytes); let tokens_before = self.projected_handoff_input_tokens(); let summary = tokio::select! { biased; @@ -164,7 +288,10 @@ impl RunCtx<'_> { } } - fn build_handoff_prompt(&self) -> String { + /// Build the summarizer prompt. `history_budget_bytes` overrides the + /// budget normally derived from `cfg.max_context_tokens`; `None` keeps the + /// derived value, which is what the proactive path uses. + fn build_handoff_prompt(&self, history_budget_bytes: Option) -> String { let mut head = String::new(); head.push_str(&format!( "[Internal handoff #{} — context reset]\n\n", @@ -192,11 +319,22 @@ impl RunCtx<'_> { (2) what was accomplished, (3) key decisions, (4) what remains, \ (5) one concrete next step. Be concise but thorough. Plain text.\n"; let history_header = "\n# Session History (oldest first)\n"; - let prompt_budget = handoff_prompt_budget_bytes( - self.cfg.max_context_tokens, - HANDOFF_MAX_OUTPUT_TOKENS, - head.len() + history_header.len() + tail.len(), - ); + let fixed_bytes = head.len() + history_header.len() + tail.len(); + // An explicit budget is the allowance for the whole prompt, so subtract + // the fixed frame from it exactly as the derived path does — otherwise + // a caller's ceiling would be silently exceeded by the frame. When the + // frame alone is larger than the budget, history drops to zero and the + // frame is what remains: it is already independently clamped + // (`HANDOFF_ORIGINAL_TASK_MAX_BYTES`, `HANDOFF_MAX_TOOL_NAMES`) and is + // not reducible from here. + let prompt_budget = match history_budget_bytes { + Some(explicit) => explicit.saturating_sub(fixed_bytes), + None => handoff_prompt_budget_bytes( + self.cfg.max_context_tokens, + HANDOFF_MAX_OUTPUT_TOKENS, + fixed_bytes, + ), + }; let mut snippets: Vec = Vec::new(); let mut snippets_bytes = 0usize; diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index 220d99f9a4..267b2d21b5 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -130,22 +130,13 @@ impl Llm { ) -> Result { let effort = cfg.thinking_effort; let result = match cfg.provider { - Provider::Anthropic => { - let v = self - .post_anthropic( - cfg, - &anthropic_body( - cfg, - system_prompt, - history, - tools, - effective_model, - effort, - ), - ) - .await?; - parse_anthropic(v) - } + Provider::Anthropic => self + .post_anthropic( + cfg, + &anthropic_body(cfg, system_prompt, history, tools, effective_model, effort), + ) + .await + .and_then(parse_anthropic), Provider::OpenRouter => { let mut body = openai_body(cfg, system_prompt, history, tools, effective_model, None); @@ -155,8 +146,9 @@ impl Llm { effective_model, cfg.prompt_caching, ); - let v = self.post_openrouter(cfg, &body).await?; - parse_openai_with_reasoning_details(v) + self.post_openrouter(cfg, &body) + .await + .and_then(parse_openai_with_reasoning_details) } Provider::OpenAi | Provider::Databricks => { self.openai_request( @@ -230,11 +222,21 @@ impl Llm { // map_err here prepends `(model-name) ` to the inner string only. // This is the single place all provider paths converge, so the mapping // is centralized and never needs to be repeated in each provider arm. + // Every arm above returns its `Result` into this mapper rather than + // using `?` — an early return would silently skip the stamp, which is + // exactly what the Anthropic and OpenRouter arms used to do. result.map_err(|e| match e { AgentError::Llm(s) => AgentError::Llm(format!("({effective_model}) {s}")), AgentError::LlmModelNotFound(s) => { AgentError::LlmModelNotFound(format!("({effective_model}) {s}")) } + // Stamped like the others: this is the error most likely to be read + // during an incident, so it must name the model whose window was + // exceeded. Without an explicit arm it would fall through `other` + // and be the only unstamped provider error. + AgentError::LlmContextExceeded(s) => { + AgentError::LlmContextExceeded(format!("({effective_model}) {s}")) + } other => other, }) } @@ -1084,6 +1086,29 @@ fn responses_body( body } +/// Narrow matcher for "the input exceeded the model's context window" provider +/// errors — the ground-truth signal that history must shrink. Only consulted +/// alongside an HTTP 400 (see the two `!status.is_success()` classification +/// sites), never on its own: the phrases below are specific, but pairing them +/// with the status keeps an unrelated 4xx that happens to quote one of them +/// from triggering a recovery. +/// +/// Deliberately tight. A generic 400 must stay `AgentError::Llm` so it remains +/// terminal — misclassifying one as recoverable would spend the whole recovery +/// budget on an error that shrinking history cannot fix, replacing a clear +/// failure with a slow one. +fn is_context_length_error(body: &str) -> bool { + let b = body.to_ascii_lowercase(); + // OpenAI/Databricks machine-readable code; the most reliable marker. + b.contains("context_length_exceeded") + // Prose forms: OpenAI's classic phrasing and the Databricks gateway's + // "context window of this model" variant seen in both bug reports. + || b.contains("maximum context length") + || b.contains("context window") + // Anthropic: "prompt is too long: N tokens > M maximum". + || b.contains("prompt is too long") +} + /// Narrow matcher for "you should be on the Responses API" provider errors, /// the signal we use to auto-upgrade. Triggers on the literal path /// `/v1/responses` (Databricks GPT-5.5 phrasing) or the prose @@ -1706,6 +1731,11 @@ fn is_retryable_transport_error(e: &reqwest::Error) -> bool { e.is_timeout() || e.is_connect() || e.is_request() } +fn is_unsupported_image_input_error(body: &str) -> bool { + body.to_ascii_lowercase() + .contains("no endpoints found that support image input") +} + /// Build the terminal `AgentError::Llm` for a `post()` exit that has given up /// retrying — persistent retryable status, transport failure, or a body-read /// break. `detail` carries the specific cause (status/body, or the transport @@ -1864,15 +1894,30 @@ where // upstream capacity — no retry was attempted, so cumulative duration // would be misleading. if status == 404 { + let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(PostError::Agent(AgentError::UnsupportedImageInput( + error_body, + ))); + } return Err(PostError::Agent(AgentError::LlmModelNotFound(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {error_body}" )))); } if !status.is_success() { + let body = read_error_body(resp).await; + // Context-window overflow is a recovery signal, not a terminal + // error: classify it here, where status and body are still separate + // values. Callers must never re-derive this from the formatted + // string — `Llm::complete` stamps the model name onto it before the + // agent loop ever sees it. + if status == 400 && is_context_length_error(&body) { + return Err(PostError::Agent(AgentError::LlmContextExceeded(format!( + "{status}: {body}" + )))); + } return Err(PostError::Agent(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await + "{status}: {body}" )))); } if let Some(len) = resp.content_length() { @@ -2117,6 +2162,9 @@ async fn openrouter_post( // about the model, and reporting a parameter problem as // `LlmModelNotFound` (or vice versa) sends the user to the wrong fix. let error_body = read_error_body(resp).await; + if is_unsupported_image_input_error(&error_body) { + return Err(AgentError::UnsupportedImageInput(error_body)); + } if error_body.contains("No endpoints found that can handle the requested parameters") { return Err(openrouter_parameter_routing_error(&error_body)); } @@ -2181,10 +2229,15 @@ async fn openrouter_post( }; } if !status.is_success() { - return Err(AgentError::Llm(format!( - "{status}: {}", - read_error_body(resp).await - ))); + let body = read_error_body(resp).await; + // Same recovery classification as the shared `post()` terminal: + // `openrouter_post` is a separate implementation with its own retry + // loop and status ladder, so it needs its own arm or OpenRouter + // agents keep the permanent context-400 stuck loop. + if status == 400 && is_context_length_error(&body) { + return Err(AgentError::LlmContextExceeded(format!("{status}: {body}"))); + } + return Err(AgentError::Llm(format!("{status}: {body}"))); } if let Some(len) = resp.content_length() { if len as usize > MAX_LLM_RESPONSE_BYTES { @@ -2487,6 +2540,8 @@ mod tests { }); let status_text = match response.status { 200 => "OK", + 400 => "Bad Request", + 413 => "Payload Too Large", 500 => "Internal Server Error", 502 => "Bad Gateway", 503 => "Service Unavailable", @@ -6018,6 +6073,8 @@ mod tests { fn status_line(status: u16) -> &'static str { match status { 200 => "200 OK", + 400 => "400 Bad Request", + 413 => "413 Payload Too Large", 401 => "401 Unauthorized", 402 => "402 Payment Required", 403 => "403 Forbidden", @@ -6131,6 +6188,240 @@ mod tests { (url, captured, attempts) } + /// Wren's rider: assert on the error emerging from `complete()` for the + /// OpenRouter path, not from `openrouter_post`. The bug was the `?` in the + /// provider arm, which is invisible from below — a low-level test can see + /// the classification but not whether the arm returns it into the + /// convergence mapper. The regression test has to cross the layer that had + /// the bug. + /// + /// Two claims here: the variant is `LlmContextExceeded` (so the agent loop + /// can recover), and the message carries the `(model)` stamp (so the arm + /// reaches the mapper at all). Measured before the fix: variant was correct + /// but UNSTAMPED, which is exactly the bypass Wren named. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_context_400_is_typed_and_stamped_through_complete() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"This model's maximum context length is 8192 tokens","code":"context_length_exceeded"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenRouter context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("or-model-xyz"), + "OpenRouter arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Same two claims on the Anthropic arm — the other `?` Wren named, and the + /// other terminal's provider phrasing ("prompt is too long"). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn anthropic_context_400_is_typed_and_stamped_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"type":"error","error":{"type":"invalid_request_error","message":"prompt is too long: 300000 tokens > 200000 maximum"}}), + }]) + .await; + let mut c = cfg(Provider::Anthropic); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "claude-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "Anthropic context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + let text = err.to_string(); + assert!( + text.contains("claude-probe-model"), + "Anthropic arm must return into the convergence mapper so the model stamp is \ + applied; got: {text}" + ); + } + + /// Negative arm for the OpenRouter terminal: an ordinary 400 must stay + /// `AgentError::Llm`. Paired with the positive above, this is what proves + /// the matcher — not the status alone — is doing the classification. The + /// body deliberately quotes "tokens" and "model", the words a loose matcher + /// would key on. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_ordinary_400_stays_plain_llm_error() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 400, + r#"{"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Negative arm for the shared `post()` terminal (OpenAI/Databricks), the + /// second of the two `!status.is_success()` sites. Same body as the + /// OpenRouter negative so the two terminals are compared on equal input. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_ordinary_400_stays_plain_llm_error() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"Invalid value for 'max_tokens': must be an integer for this model","code":"invalid_value"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "an ordinary 400 must stay a terminal AgentError::Llm, got: {err:?}" + ); + } + + /// Positive arm for the shared `post()` terminal: OpenAI's machine-readable + /// `context_length_exceeded` code classifies as recoverable. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_context_400_is_typed_through_complete() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","type":"invalid_request_error","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::LlmContextExceeded(_)), + "OpenAI context-window 400 must classify as LlmContextExceeded, got: {err:?}" + ); + assert!( + err.to_string().contains("gpt-probe-model"), + "expected the convergence mapper's model stamp, got: {err}" + ); + } + + /// A context-window 400 must NOT trip the Responses-API auto-upgrade. True + /// by construction — `try_upgrade` matches only `AgentError::Llm` and the + /// typed variant can never reach it — but asserted because the guarantee + /// lives in a pattern match one refactor away from widening, and a silent + /// sticky upgrade would reroute every later OpenAI call for the process. + /// + /// `openai_api = Auto` is load-bearing in BOTH arms: `try_upgrade` is only + /// consulted under `Auto` (`llm.rs:587`), so with the test helper's default + /// `Chat` the upgrade path is disabled outright and the negative below would + /// pass without observing anything. The control caught exactly that. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn context_400_does_not_trip_responses_upgrade() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model's maximum context length is 8192 tokens.","code":"context_length_exceeded"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + c.openai_api = OpenAiApi::Auto; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!(matches!(err, AgentError::LlmContextExceeded(_))); + assert!( + !llm.auto_upgraded.load(Ordering::Relaxed), + "a context-window 400 must not latch the Responses-API upgrade" + ); + // Positive control: the same helper DOES latch on a genuine + // "use the Responses API" error, so the negative above is a real + // observation and not a probe that can never fire. + let (base_url2, _c2) = spawn_sequence_stub(vec![StubHttpResponse { + status: 400, + body: json!({"error":{"message":"This model is only supported in /v1/responses"}}), + }]) + .await; + let mut c2 = cfg(Provider::OpenAi); + c2.base_url = base_url2; + c2.openai_api = OpenAiApi::Auto; + let llm2 = Llm::new(&c2).unwrap(); + let _ = complete_model(&llm2, &c2, "gpt-probe-model").await; + assert!( + llm2.auto_upgraded.load(Ordering::Relaxed), + "control: a genuine Responses-API error must latch the upgrade" + ); + } + + /// The `status == 400` conjunct is load-bearing, not belt-and-braces: the + /// recovery ladder is only a correct response to an INPUT-SIZE rejection. + /// A 403 whose body happens to quote context-window prose (a guardrail + /// echoing the request, say) is a permission failure — shrinking history + /// cannot fix it, so classifying it as recoverable would burn the whole + /// recovery budget on three doomed summarize round-trips and turn a clear + /// immediate error into a slow one. + /// + /// 413 (Payload Too Large) is the right probe status, and picking it took a + /// measurement: my first attempt used 403, which BOTH ladders intercept + /// earlier (shared `post()` maps 401/403 to `LlmAuth`; `openrouter_post()` + /// has its own 403 arm), so those probes never reached the classification + /// site at all and the mutant with the conjunct deleted survived them. 413 + /// is intercepted by neither ladder, so it reaches the same + /// `!status.is_success()` terminal the 400 does — and it is the most + /// plausible real-world carrier of size prose on a non-400. One arm per + /// terminal site. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openai_413_with_context_prose_is_not_recoverable() { + let (base_url, _captured) = spawn_sequence_stub(vec![StubHttpResponse { + status: 413, + body: json!({"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}), + }]) + .await; + let mut c = cfg(Provider::OpenAi); + c.base_url = base_url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "gpt-probe-model") + .await + .unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + + /// Same claim at the OpenRouter terminal, which has its own status ladder. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_413_with_context_prose_is_not_recoverable() { + let (url, _captured, _attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 413, + r#"{"error":{"message":"payload too large: this model's maximum context length is 8192 tokens"}}"#, + )]) + .await; + let mut c = cfg(Provider::OpenRouter); + c.base_url = url; + let llm = Llm::new(&c).unwrap(); + let err = complete_model(&llm, &c, "or-model-xyz").await.unwrap_err(); + assert!( + matches!(err, AgentError::Llm(_)), + "only a 400 may classify as a context overflow; a 413 must stay terminal, got: \ + {err:?}" + ); + } + /// A 403 (guardrail/moderation/permission rejection, per OpenRouter docs) /// must NOT be classified as `LlmAuth`: refreshing a static key returns /// the identical key, so retrying would just waste a duplicate request. @@ -6217,6 +6508,34 @@ mod tests { ); } + /// A provider's explicit image-capability rejection is a recoverable typed + /// error, not a missing model. The agent loop uses this signal to remove the + /// image from history before retrying the next LLM round. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn openrouter_post_404_unsupported_image_is_typed_and_not_retried() { + let (url, _captured, attempts) = spawn_openrouter_stub(vec![CannedResponse::new( + 404, + r#"{"error":{"message":"No endpoints found that support image input"}}"#, + )]) + .await; + let http = Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap(); + let err = openrouter_post(&http, &format!("{url}/x"), &json!({}), "key") + .await + .unwrap_err(); + assert!( + matches!(&err, AgentError::UnsupportedImageInput(s) if s.contains("support image input")), + "image rejection must reach the history-recovery path: got {err:?}" + ); + assert_eq!( + attempts.load(std::sync::atomic::Ordering::SeqCst), + 1, + "a deterministic capability rejection must not be retried" + ); + } + /// Every other 404 still maps to `LlmModelNotFound`, including one that /// shares the `No endpoints found` prefix but is about the model rather than /// the parameters — the discriminator is narrow enough that a genuinely diff --git a/crates/buzz-agent/src/types.rs b/crates/buzz-agent/src/types.rs index e386421981..4a856f7a87 100644 --- a/crates/buzz-agent/src/types.rs +++ b/crates/buzz-agent/src/types.rs @@ -388,6 +388,22 @@ pub enum AgentError { Llm(String), LlmAuth(String), LlmModelNotFound(String), + /// The provider rejected the request because the input exceeded the + /// model's context window (an HTTP 400 whose body names a context-length + /// overflow). Typed rather than folded into [`Self::Llm`] because the + /// agent loop treats it as a *recovery* signal, not a terminal error: it + /// is the only ground-truth indication that history must shrink, needing + /// no window estimate that could itself be miscalibrated. + /// + /// Classified where the HTTP status and body are still separate values, so + /// the loop never has to sniff a formatted string — by the time an error + /// leaves `Llm::complete` it has already been decorated with the model + /// name. + LlmContextExceeded(String), + /// The provider explicitly rejected image content for the selected model. + /// Kept distinct so the agent loop can remove the unsupported image from + /// replayed history and give the model a recoverable tool error. + UnsupportedImageInput(String), Mcp(String), Cancelled, } @@ -399,6 +415,8 @@ impl std::fmt::Display for AgentError { Self::Llm(s) => write!(f, "llm: {s}"), Self::LlmAuth(s) => write!(f, "llm auth: {s}"), Self::LlmModelNotFound(s) => write!(f, "llm model not found: {s}"), + Self::LlmContextExceeded(s) => write!(f, "llm context exceeded: {s}"), + Self::UnsupportedImageInput(s) => write!(f, "llm image input unsupported: {s}"), Self::Mcp(s) => write!(f, "mcp: {s}"), Self::Cancelled => write!(f, "cancelled"), } diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 5b660da48c..1b7f346162 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -12,6 +12,7 @@ //! (use a large value, e.g. 999, to simulate hang) //! FAKE_MCP_RESULT_SIZE=N — `tools/call` returns an N-byte text result //! (default: the literal "ok"); grows history +//! FAKE_MCP_IMAGE_RESULT=1 — `tools/call` returns text plus a PNG image block //! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup //! (for tests that want to verify the child died) //! FAKE_MCP_SPAWN_GRANDCHILD=1 @@ -300,10 +301,18 @@ fn main() { } else { "ok".to_owned() }; + let content = if env_flag("FAKE_MCP_IMAGE_RESULT") { + json!([ + { "type": "text", "text": result_text }, + { "type": "image", "data": "aW1n", "mimeType": "image/png" }, + ]) + } else { + json!([{ "type": "text", "text": result_text }]) + }; write_response( id, json!({ - "content": [{ "type": "text", "text": result_text }], + "content": content, "isError": false, }), ); diff --git a/crates/buzz-agent/tests/fake_llm.rs b/crates/buzz-agent/tests/fake_llm.rs index ef6f9d2d80..4253ef329c 100644 --- a/crates/buzz-agent/tests/fake_llm.rs +++ b/crates/buzz-agent/tests/fake_llm.rs @@ -57,9 +57,26 @@ async fn spawn_fake_llm(responses: Vec) -> String { url } +struct CannedResponse { + status: u16, + body: Value, +} + /// Like `spawn_fake_llm` but also captures the full JSON request body from each /// incoming HTTP request. Returns (url, captured_requests). async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc>>) { + spawn_capturing_fake_llm_with_statuses( + responses + .into_iter() + .map(|body| CannedResponse { status: 200, body }) + .collect(), + ) + .await +} + +async fn spawn_capturing_fake_llm_with_statuses( + responses: Vec, +) -> (String, Arc>>) { 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))); @@ -122,15 +139,22 @@ async fn spawn_capturing_fake_llm(responses: Vec) -> (String, Arc) -> CapturingLlm { + spawn_capturing_llm_with_status(responses.into_iter().map(|v| (200u16, v)).collect()).await +} + +/// Like `spawn_capturing_llm` but each canned response carries its own HTTP +/// status, so a test can serve a real provider rejection (e.g. a context-window +/// 400) instead of only success bodies. +async fn spawn_capturing_llm_with_status(responses: Vec<(u16, Value)>) -> CapturingLlm { 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))); @@ -66,14 +73,19 @@ async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { captured.lock().await.push(req); } - let body = queue + let (status, body) = queue .lock() .await .pop_front() - .unwrap_or_else(|| json!({ "error": "no canned response" })); + .unwrap_or_else(|| (200, json!({ "error": "no canned response" }))); let body_s = serde_json::to_string(&body).unwrap(); + let reason = match status { + 200 => "OK", + 400 => "Bad Request", + _ => "Error", + }; let resp = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body_s.len(), body_s, ); let _ = sock.write_all(resp.as_bytes()).await; @@ -2281,3 +2293,1293 @@ fn reply_guard_rejects_unparseable_toggle() { "expected the offending key in the error, got: {stderr}" ); } + +/// A prompt large enough that the recovery ladder's halving stays above +/// `HANDOFF_MIN_PROMPT_BUDGET_BYTES` (4 KiB) for all three rungs. +/// +/// This is load-bearing, not decoration: with a tiny history the ladder +/// correctly refuses on the FIRST rung (halving a 49-byte history lands at 24 +/// bytes, far under the floor), so a small fixture cannot exercise recovery at +/// all — it exercises the floor. `marker` is embedded so the prompt is still +/// identifiable in a captured request body. +fn large_prompt(marker: &str) -> String { + let mut s = String::with_capacity(64 * 1024 + marker.len()); + s.push_str(marker); + s.push(' '); + while s.len() < 64 * 1024 { + s.push_str("filler context to make the history realistically large. "); + } + s +} + +/// OpenAI-compatible context-window rejection body, matching the shape the +/// provider actually returns on overflow. +fn openai_context_length_error() -> Value { + json!({ + "error": { + "message": "This model's maximum context length is 8192 tokens. \ + However, your messages resulted in 20000 tokens.", + "type": "invalid_request_error", + "code": "context_length_exceeded", + } + }) +} + +/// A 400 that is NOT a context-window overflow — the negative control for the +/// matcher. Deliberately quotes "tokens" and "model", the words a sloppy +/// matcher would key on. +fn openai_ordinary_400() -> Value { + json!({ + "error": { + "message": "Invalid value for 'max_tokens': must be an integer for this model", + "type": "invalid_request_error", + "code": "invalid_value", + } + }) +} + +/// THE BUG. A provider context-window 400 must be recovered from in-loop, not +/// propagated out of `run()`. +/// +/// Without the reactive path this is a permanent stick, and the mechanism is +/// what makes it permanent rather than transient: a failed request reports no +/// usage, so `last_request_input_tokens` stays frozen at the last SUCCESSFUL +/// (sub-threshold) reading, `should_handoff()` therefore returns false forever, +/// and the in-memory session keeps the same oversized history. Every later +/// prompt in that session fails identically, for the life of the session. +/// (Restarting the agent clears it — history is not written to disk — which is +/// why the only workaround today is a restart.) +/// +/// The sequence here reproduces exactly that state: request 1 succeeds and +/// reports usage well UNDER the threshold (so the proactive gate is provably +/// not what fires), request 2 is rejected with a context-window 400. The agent +/// must force a handoff and retry, so the prompt still ends in a normal +/// `end_turn` rather than a JSON-RPC error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_window_400_recovers_instead_of_sticking() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: succeeds, usage 10 tokens — far under any threshold. + (200, openai_text_with_usage("ack", 10)), + // req 2: the overflow rejection. + (400, openai_context_length_error()), + // req 3: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 4: the retried completion, now on fresh history. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + // Large window + large byte budget: neither proactive gate can be + // what produces the handoff, so a handoff here is attributable to + // the reactive path alone. + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Cap of 0: proves the forced path bypasses `max_handoffs`. Any + // gated handoff is impossible under this setting. + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"first prompt, succeeds"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0["result"].get("stopReason").is_some(), + "first prompt should succeed: {r0}" + ); + + // Second prompt: its first completion is rejected for context overflow. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("second-prompt-overflows")}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!( + r1.get("error").is_none(), + "context-window 400 must be recovered in-loop, not returned as an error: {r1} \ + stderr={}", + h.stderr_text() + ); + assert_eq!( + r1["result"]["stopReason"], + "end_turn", + "expected the turn to finish after recovery: {r1} stderr={}", + h.stderr_text() + ); + // 4 requests = the rejected one, the summarize, and the retry. 2 would mean + // no recovery was attempted. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 4, + "expected reject + summarize + retry (4 reqs total), saw {captured} — stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("provider reported context overflow; forcing handoff"), + "expected the forced-handoff log line, got: {stderr}" + ); + h.shutdown().await; +} + +/// A successful recovery must actually send the recovered completion, even when +/// `max_rounds` is finite. `round` is incremented BEFORE the completion that +/// gets rejected, so a naive `continue` after recovery re-enters the loop with +/// the rejected attempt already charged against the cap: with +/// `BUZZ_AGENT_MAX_ROUNDS=1` the turn would return `max_turn_requests` after +/// destructively resetting history, having never sent the retry. That silently +/// converts "recovered" into "history destroyed, question unanswered" — worse +/// than the error it replaced, because the user gets a stop reason rather than a +/// failure. +/// +/// The default `max_rounds` is 0 (unbounded), which is why the rest of the +/// matrix cannot see this: the cap check at the top of the loop never fires. +/// +/// `max_rounds=1` is also the tightest possible setting, so it pins the +/// boundary: exactly one round is authorized, the rejected request must not +/// consume it, and the retry must be the request that spends it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_retry_is_sent_under_a_finite_round_cap() { + let llm = spawn_capturing_llm_with_status(vec![ + // req 1: the overflow rejection (round 1 charged before it is sent). + (400, openai_context_length_error()), + // req 2: the forced handoff's summarize() call. + (200, openai_text("recovered handoff summary")), + // req 3: the retried completion. Under the bug this is never sent. + (200, openai_text_with_usage("done after recovery", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + // The whole point: a finite cap, at its tightest. + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("overflows-under-finite-cap")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "context-window 400 must be recovered in-loop: {r0} stderr={}", + h.stderr_text() + ); + // The discriminator. `max_turn_requests` here means recovery ran, history + // was reset, and the turn ended without ever asking the model again. + assert_eq!( + r0["result"]["stopReason"], + "end_turn", + "a recovered turn must finish by answering, not by hitting the round cap: {r0} \ + stderr={}", + h.stderr_text() + ); + // 3 requests = reject + summarize + retry. 2 would mean the retry was + // never sent (the bug); the outcome assertion alone cannot tell those apart + // if the stop reason were ever produced some other way. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 3, + "expected reject + summarize + retry (3 reqs), saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// The finite round cap must still bind for ORDINARY rounds — the recovery +/// refund must not become a general amnesty. With `max_rounds=1` and no context +/// overflow anywhere, a model that keeps requesting tool calls gets exactly one +/// completion and then `max_turn_requests`. +/// +/// Without this arm, "make the recovered retry possible" is satisfiable by +/// deleting the cap, and the test above would still pass. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn finite_round_cap_still_binds_without_a_context_overflow() { + let llm = spawn_capturing_llm_with_status(vec![ + // Round 1: a tool call, which would normally drive another round. + ( + 200, + openai_tool_call("tc1", "dev__shell", json!({"command": "true"})), + ), + // Never reached: the cap must stop the turn before a second completion. + (200, openai_text_with_usage("should not be sent", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_ROUNDS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"drive a tool call"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert_eq!( + r0["result"]["stopReason"], + "max_turn_requests", + "an ordinary finite cap must still bind: {r0} stderr={}", + h.stderr_text() + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "exactly one completion is authorized by max_rounds=1, saw {captured} — stderr={}", + h.stderr_text() + ); + h.shutdown().await; +} + +/// Prompt-exactly-once across a forced handoff: the live user prompt must be +/// retained in the fresh history exactly once — not dropped (the model would +/// answer a question it can no longer see) and not duplicated (a doubled prompt +/// re-inflates the context we just shrank, and can produce a doubled action). +/// +/// Asserted on the retry request's own message array, which is the only place +/// the post-reset history is observable from outside. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn forced_handoff_retains_live_prompt_exactly_once() { + const MARKER: &str = "unique-live-prompt-marker-7f3a"; + let llm = spawn_capturing_llm_with_status(vec![ + (200, openai_text_with_usage("ack", 10)), + (400, openai_context_length_error()), + (200, openai_text("summary body")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"warmup"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p0)).await; + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt(MARKER)}]}), + ) + .await; + let r1 = h.recv_until(|v| v["id"] == json!(p1)).await; + assert!(r1.get("error").is_none(), "expected recovery: {r1}"); + + let captured = llm.captured.lock().await; + let retry = captured + .last() + .expect("at least one captured request") + .clone(); + drop(captured); + let messages = retry["messages"] + .as_array() + .unwrap_or_else(|| panic!("retry request had no messages array: {retry}")); + let occurrences = messages + .iter() + .filter(|m| { + m["content"] + .as_str() + .map(|s| s.contains(MARKER)) + .unwrap_or(false) + }) + .count(); + assert_eq!( + occurrences, 1, + "live prompt must appear exactly once in post-handoff history, saw {occurrences} in \ + {messages:#?}" + ); + h.shutdown().await; +} + +/// Negative control at the loop layer: an ordinary 400 must stay terminal. +/// +/// This is the arm that keeps the recovery narrow. If the matcher were loose, +/// this request would be classified as recoverable, the agent would spend its +/// whole recovery budget summarizing, and a clear immediate failure would +/// become a slow one — with three wasted provider round-trips. Exactly one +/// request, and the prompt returns an error. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn ordinary_400_stays_terminal_and_triggers_no_recovery() { + let llm = spawn_capturing_llm_with_status(vec![(400, openai_ordinary_400())]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_HANDOFFS", "3"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"hello"}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_some(), + "an ordinary 400 must surface as an error, got: {r0}" + ); + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, + 1, + "an ordinary 400 must not trigger a recovery attempt; saw {captured} requests — \ + stderr={}", + h.stderr_text() + ); + let stderr = h.stderr_text(); + assert!( + !stderr.contains("provider reported context overflow"), + "ordinary 400 must not be classified as a context overflow, got: {stderr}" + ); + h.shutdown().await; +} + +/// The recovery budget must be finite: a provider that rejects every request +/// for context overflow — including the retries — has to surface the error +/// rather than being rescued forever. `max_rounds` cannot bound this (it +/// defaults to 0/unbounded), so the per-`run()` recovery budget is the only +/// thing standing between this case and an infinite loop. +/// +/// The stub returns a context-400 to EVERY request, so a missing bound shows up +/// as a hang rather than a wrong answer — hence the explicit timeout, which is +/// part of the assertion. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn context_recovery_budget_exhaustion_surfaces_the_error() { + // Enough canned 400s that the queue is never the thing that stops the loop; + // the fallback response is also a 400-shaped body under this helper only if + // queued, so keep the queue generously long. + let responses: Vec<(u16, Value)> = (0..40) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("always-overflows")}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("recovery must be bounded — prompt never returned, so the rescue loop is unbounded"); + assert!( + r0.get("error").is_some(), + "exhausted recovery must surface the provider error, got: {r0}" + ); + let msg = r0["error"]["message"].as_str().unwrap_or_default(); + assert!( + msg.contains("context"), + "surfaced error should be the provider's own context-window error, got: {msg}" + ); + // Discriminate WHICH bound stopped the loop. Both the budget and the prompt + // floor produce a surfaced error, so the assertion above passes either way + // — and the floor can fire on the first rung without the budget ever being + // consumed, which would make this test silently exercise a different + // mechanism than its name claims. Pin the budget explicitly. + let stderr = h.stderr_text(); + assert!( + stderr.contains("context recovery budget spent"), + "the per-run recovery BUDGET must be what stops the loop here, not the prompt floor; \ + got: {stderr}" + ); + // Corroboration: every rung actually ran a forced handoff. + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 3, + "expected all 3 recovery rungs to be attempted before giving up, saw {rungs} — \ + stderr={stderr}" + ); + h.shutdown().await; +} + +/// The prompt-budget floor, observed on its own. A context-window 400 on a +/// SMALL history must refuse to rescue rather than halve toward zero: the +/// overflow is then dominated by what a handoff cannot shrink (system prompt, +/// tool schemas, the live user prompt), so shrinking history further would only +/// issue smaller doomed requests in place of a clear error. +/// +/// The outcome — a surfaced error — is identical to budget exhaustion, so this +/// asserts the discriminating evidence instead: the floor log line, and that +/// ZERO forced handoffs were attempted. Without the floor the ladder would spend +/// all three rungs summarizing a 40-byte history, which is the behavior this +/// arm exists to forbid. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn small_history_context_400_refuses_rescue_at_the_prompt_floor() { + let responses: Vec<(u16, Value)> = (0..10) + .map(|_| (400, openai_context_length_error())) + .collect(); + let llm = spawn_capturing_llm_with_status(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"tiny"}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(20), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("must not loop — the floor should stop the rescue immediately"); + assert!( + r0.get("error").is_some(), + "a context 400 with no shrinkable history must surface the error, got: {r0}" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("below the") && stderr.contains("floor"), + "the prompt-budget FLOOR must be what stops this, not the recovery budget; got: {stderr}" + ); + let rungs = stderr + .matches("provider reported context overflow; forcing handoff") + .count(); + assert_eq!( + rungs, 0, + "no rescue should be attempted below the floor, saw {rungs} — stderr={stderr}" + ); + // Exactly one request: the rejected one. No summarize, no retry. + let captured = llm.captured.lock().await.len(); + assert_eq!( + captured, 1, + "expected no rescue round-trips below the floor, saw {captured} requests" + ); + h.shutdown().await; +} + +/// The recovery ladder must actually SHRINK, not just re-summarize at the size +/// that was already rejected. +/// +/// Observed on the summarize request's own body — the only externally visible +/// consequence of the prompt budget. The rejected completion carried the full +/// history; the rescue's summarize prompt must be materially smaller. Without +/// this arm, deleting the halving entirely leaves every other test green: they +/// assert that a handoff HAPPENED, and a handoff at the rejected size still +/// happens (it just cannot escape a real overflow, which a stub does not +/// reproduce). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_the_summarize_prompt_below_the_rejected_size() { + let llm = spawn_capturing_llm_with_status(vec![ + (400, openai_context_length_error()), + (200, openai_text("summary")), + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!(r0.get("error").is_none(), "expected recovery: {r0}"); + + let captured = llm.captured.lock().await.clone(); + assert!( + captured.len() >= 2, + "expected at least reject + summarize, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rejected = content_bytes(&captured[0]); + let summarize = content_bytes(&captured[1]); + assert!( + rejected > 0 && summarize > 0, + "empty measurement is not a result: rejected={rejected} summarize={summarize}" + ); + // Halving from the rejected size lands near 0.5x; 0.75x leaves headroom for + // the summarizer's fixed frame while still failing if no shrink happened. + assert!( + (summarize as f64) < 0.75 * (rejected as f64), + "rescue summarize prompt ({summarize} bytes) must be materially smaller than the \ + rejected request ({rejected} bytes) — the ladder is not shrinking" + ); + h.shutdown().await; +} + +/// The ladder must shrink between RUNGS, not just once on entry. +/// +/// This arm exists because a mutant that pins `shift` to `1` — deleting the +/// `attempts` dependence, so every rung rebuilds the same budget — SURVIVED the +/// whole suite. It had to: `attempts` is 0 on the first rung, so `shift = 1` IS +/// production there, and every other arm stops at rung 1. The single-rung shrink +/// arm above cannot see this; only a fixture that forces a SECOND rung can. +/// +/// The forcing move is the realistic one the ladder was designed for: the +/// summarize call travels the same provider path, so rung 1's summarize is +/// itself rejected for context overflow (`Skipped`), and rung 2 must come back +/// with a materially smaller summarizer prompt. +/// +/// Budgets: history is ~64 KB, so rung 1 asks for ~32 KB and rung 2 for ~16 KB, +/// both comfortably above the 4 KiB floor — the floor must not be what +/// separates them, or this would measure the wrong mechanism. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_shrinks_further_on_each_rung() { + let llm = spawn_capturing_llm_with_status(vec![ + // 1: the completion that overflows. + (400, openai_context_length_error()), + // 2: rung-1 summarize, rejected the same way -> Skipped -> next rung. + (400, openai_context_length_error()), + // 3: rung-2 summarize succeeds. + (200, openai_text("summary")), + // 4: the retried completion. + (200, openai_text_with_usage("done", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ("BUZZ_AGENT_MAX_HANDOFFS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": large_prompt("rung-shrink-probe")}]}), + ) + .await; + let r0 = h.recv_until(|v| v["id"] == json!(p0)).await; + assert!( + r0.get("error").is_none(), + "expected recovery on the second rung: {r0}" + ); + + // The second rung must actually have been taken — otherwise the byte + // comparison below would compare rung 1 against the retry. + let stderr = h.stderr_text(); + assert!( + stderr.contains("did not run; shrinking further"), + "rung 1 must have been Skipped so rung 2 runs; got: {stderr}" + ); + assert!( + !stderr.contains("below the"), + "the prompt FLOOR must not be involved in this fixture; got: {stderr}" + ); + + let captured = llm.captured.lock().await.clone(); + assert_eq!( + captured.len(), + 4, + "expected reject + rung1 summarize + rung2 summarize + retry, saw {}", + captured.len() + ); + let content_bytes = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|ms| { + ms.iter() + .filter_map(|m| m["content"].as_str()) + .map(str::len) + .sum() + }) + .unwrap_or(0) + }; + let rung1 = content_bytes(&captured[1]); + let rung2 = content_bytes(&captured[2]); + assert!( + rung1 > 0 && rung2 > 0, + "empty measurement is not a result: rung1={rung1} rung2={rung2}" + ); + assert!( + (rung2 as f64) < 0.75 * (rung1 as f64), + "each rung must shrink: rung2 ({rung2} bytes) is not materially smaller than rung1 \ + ({rung1} bytes) — the budget is not tracking `attempts`" + ); + h.shutdown().await; +} + +/// Gate 5, and the DIRECTION the clearing protects: not a spurious handoff, a +/// MISSED one. After a reactive reset the stale `last_request_input_tokens` +/// describes history that no longer exists, and its paired byte baseline +/// describes the pre-reset (larger) history — so `grown` stays near zero and the +/// projection collapses to the stale sub-threshold token count. The gate goes +/// BLIND until history exceeds its pre-reset size. +/// +/// Constructing the divergence takes three turns, and two of the constraints are +/// load-bearing — a first attempt with a simpler fixture produced traces +/// BYTE-IDENTICAL between the fix and its deletion: +/// * Turn 1 must stay UNDER the gate threshold, or the proactive handoff fires +/// first and consumes the queue slot the overflow was meant to land in — no +/// usage is ever recorded, both variants sit at `None`, and the test measures +/// nothing. +/// * The post-recovery retry must report NO usage. A usage-bearing response +/// overwrites both fields with coherent values on the spot, which makes the +/// clear genuinely redundant and the mutant equivalent. The reachable window +/// is exactly when the retry omits usage and the stale pair survives. +/// Turn 3 then carries a large prompt: a cleared baseline falls through to the +/// byte signal and hands off, while the stale pair projects +/// `10 + (190KB - 100KB)` = ~90k tokens, under the 180k threshold, and does not. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reactive_reset_clears_usage_baseline_so_the_gate_is_not_blind() { + // ~100 KB: under the 180 KB byte-fallback threshold, so turn 1 does NOT + // trip the proactive gate, but large enough to be the stale `measured_bytes` + // that suppresses `grown` later. + let mut medium = String::with_capacity(100 * 1024); + medium.push_str("turn-one-medium "); + while medium.len() < 100 * 1024 { + medium.push_str("padding under the byte fallback threshold. "); + } + // ~190 KB: over the threshold, so a CLEARED baseline must hand off. + let mut big = String::with_capacity(190 * 1024); + big.push_str("turn-three-large "); + while big.len() < 190 * 1024 { + big.push_str("padding to exceed the byte fallback threshold. "); + } + + let llm = spawn_capturing_llm_with_status(vec![ + // Turn 1: succeeds, reporting a SMALL usage reading against a ~100 KB + // history. This is the pair that goes stale. + (200, openai_text_with_usage("ack-medium", 10)), + // Turn 2: the overflow. + (400, openai_context_length_error()), + // Turn 2: the forced handoff's summarize. + (200, openai_text("forced summary")), + // Turn 2: the retry — NO usage block, so the baseline is not refreshed. + (200, openai_text("recovered, no usage reported")), + // Turn 3: with a cleared baseline a gated summarize comes first; with a + // stale one this slot is the completion instead. Spares so an exhausted + // queue is never what ends a turn. + (200, openai_text("gated summary")), + (200, openai_text_with_usage("done", 10)), + (200, openai_text_with_usage("spare-1", 10)), + (200, openai_text_with_usage("spare-2", 10)), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "200000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "8192"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + // Must permit a GATED handoff — turn 3 observes the proactive gate, + // which a cap of 0 would forbid. + ("BUZZ_AGENT_MAX_HANDOFFS", "5"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: under threshold, records the usage pair. + let p0 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": medium}]}), + ) + .await; + let r0 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p0)), + ) + .await + .expect("turn 1 must return"); + assert!(r0.get("error").is_none(), "turn 1 should succeed: {r0}"); + assert!( + !h.stderr_text().contains("handoff #"), + "precondition: turn 1 must NOT hand off, or no usage pair is recorded and this test \ + measures nothing. stderr={}", + h.stderr_text() + ); + + // Turn 2: small prompt, overflow, reactive recovery. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"small, overflows"}]}), + ) + .await; + let r1 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p1)), + ) + .await + .expect("turn 2 must return"); + assert!(r1.get("error").is_none(), "turn 2 should recover: {r1}"); + assert!( + h.stderr_text() + .contains("provider reported context overflow; forcing handoff"), + "precondition: the reactive path must have run in turn 2. stderr={}", + h.stderr_text() + ); + let handoffs_after_turn2 = h.stderr_text().matches("handoff #").count(); + + // Turn 3: large prompt. A cleared baseline sees it via the byte signal and + // hands off; a stale pair under-projects and stays blind. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": big}]}), + ) + .await; + let r2 = tokio::time::timeout( + Duration::from_secs(25), + h.recv_until(|v| v["id"] == json!(p2)), + ) + .await + .expect("turn 3 must return"); + assert!(r2.get("error").is_none(), "turn 3 should succeed: {r2}"); + let stderr = h.stderr_text(); + let handoffs_after_turn3 = stderr.matches("handoff #").count(); + assert!( + handoffs_after_turn3 > handoffs_after_turn2, + "turn 3 must produce a GATED handoff ({handoffs_after_turn2} before, \ + {handoffs_after_turn3} after): the reactive reset must clear the usage baseline, or the \ + proactive gate under-projects and stays blind to an oversized history. stderr={stderr}" + ); + h.shutdown().await; +} + +// ─── Tests: per-turn handoff cap semantics ─────────────────────────────────── + +/// A session that has already performed N handoffs in previous turns must still +/// compact on subsequent turns — the per-session lifetime kill switch is gone. +/// +/// Mechanism: the gate fires at the start of each round, comparing +/// `last_request_input_tokens` (stored by the previous response) against the +/// token threshold. So: +/// - Turn 1 complete() returns usage=950 (> threshold=900). Turn ends; usage stored. +/// - Turn 2 round 0: 950 >= 900 → handoff. post-handoff complete() returns usage=950. +/// Session `handoff_count` is now 1; `turn_handoff_count` was just reset to 0 at +/// turn start and is now 1. +/// - Turn 3 round 0: `turn_handoff_count` resets to 0; session count is 1 but +/// the gate uses `turn_handoff_count` → cap not reached → handoff fires again. +/// +/// Without the fix (`handoff_count` compared against cap, never reset): +/// session count after turn 2 = 1 >= max_handoffs=1 → gate permanently blocked +/// for all subsequent turns → history grows until provider wall. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_resets_per_turn_not_per_session() { + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (over threshold) + // req 2: turn 2 pre-flight summarize → summary text + // req 3: turn 2 complete() → usage=950 (re-arms gate for turn 3) + // req 4: turn 3 pre-flight summarize → summary text ← cap reset proves this fires + // req 5: turn 3 complete() → done + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("ack-t1", 950), // turn 1: stores high usage + openai_text("summary-t2"), // turn 2: pre-flight summarize + openai_text_with_usage("done-t2", 950), // turn 2: post-handoff, re-arms gate + openai_text("summary-t3"), // turn 3: pre-flight summarize (cap reset) + openai_text_with_usage("done-t3", 10), // turn 3: post-handoff complete + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + // Cap of 1 per turn. Before the fix this permanently disables the + // gate once session handoff_count reaches 1. + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + // Turn 1: no prior usage; preflight skips (byte-fallback not triggered by + // tiny prompt). complete() stores usage=950. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 1"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + assert_eq!( + llm.captured.lock().await.len(), + 1, + "turn 1 must produce exactly 1 LLM request" + ); + + // Turn 2: 950 >= threshold=900 → handoff fires. Session handoff_count: 1. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 2"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p2)).await; + assert_eq!( + llm.captured.lock().await.len(), + 3, + "turn 2 must produce 2 LLM requests (summarize + complete), 3 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #1"), + "expected first handoff log after turn 2; got: {stderr}" + ); + + // Turn 3: turn_handoff_count resets to 0 → gate fires again despite + // session handoff_count=1 == cap=1. + let p3 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"turn 3"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p3)).await; + assert_eq!( + llm.captured.lock().await.len(), + 5, + "turn 3 must also produce 2 LLM requests (per-turn cap reset → handoff fires again), \ + 5 total" + ); + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff #2"), + "expected second handoff log after turn 3 (cap reset); got: {stderr}" + ); + + h.shutdown().await; +} + +/// Within a single turn, the per-turn cap still bounds the number of handoffs. +/// A turn that exceeds `max_handoffs` compaction attempts must emit a WARN and +/// fall back to truncation — it must NOT compact indefinitely. +/// +/// Mechanism: with cap=1 and a multi-round turn (tool call in round 1 → round 2), +/// the pre-flight handoff fires at the start of round 1 (usage from a *previous* +/// turn is high). After the compaction, the post-handoff complete() in round 1 +/// returns a tool call, causing a second round. Round 2's preflight sees that +/// turn_handoff_count=1 == max_handoffs=1, so it refuses and emits WARN. +/// +/// A steer is injected while the run is active to prove that the steer path +/// does NOT reset `handoff_attempts` — the cap must still fire on round 1 with +/// no second summarize call. +/// +/// This test requires a fake MCP server to produce a tool-call round. +/// It drives via `fake-mcp` — the same binary used in other multi-round tests. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handoff_cap_binds_within_a_single_turn() { + // LLM call sequence in turn 2 (turn 1 seeds the usage): + // req 1: turn 1 complete() → usage=950 (over threshold=900) + // req 2: turn 2 round 0 summarize() → summary (handoff_attempts: 0→1) + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // [fake-mcp tool executes; steer queued while run is active] + // req 4: turn 2 round 1 preflight → 950 >= 900 AND attempts=1 >= max=1 + // → WARN, skip (cap exhausted for this turn) + // req 5: turn 2 round 1 complete() → end_turn (steer text folded into messages) + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + // Build a tool-call response that also carries usage so the gate re-arms + // on round 1's preflight (without usage, last_request_input_tokens is None + // after the handoff clears it, and the byte-fallback won't fire on tiny history). + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-1", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed high usage + openai_text("handoff-summary"), // turn 2 round 0: summarize + tool_call_with_usage, // turn 2 round 0: tool call + usage (re-arms) + openai_text_with_usage("end_turn_text", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + // Init with the fake MCP server so test_tool is available. + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "cap_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: triggers a handoff at round 0, then a tool call, then round 1 + // where the cap is already exhausted. A steer is injected while the run + // is active to prove mid-turn steers cannot reset `handoff_attempts`. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"do work"}]}), + ) + .await; + + // Drain until the final response, approving tool-permission requests, + // capturing the activeRunId once it is broadcast, sending one steer, + // and verifying that it is accepted in the live run. + let mut run_id: Option = None; + let mut steer_id: i64 = -1; + let mut steer_accepted = false; + loop { + let v = h.recv().await; + + // Capture the run id from the first session/update that carries it, + // then immediately queue a steer. This must happen before round 1 so + // the steer text is present but the cap check still fires — proving + // the counter is not reset by the steer path. + if run_id.is_none() { + if let Some(rid) = v["params"]["update"]["_meta"]["goose"]["activeRunId"].as_str() { + run_id = Some(rid.to_owned()); + steer_id = h + .send( + "_goose/unstable/session/steer", + json!({ + "sessionId": sid, + "expectedRunId": rid, + "prompt": [{"type":"text","text":"STEER-CANARY: also consider the edge case"}], + }), + ) + .await; + } + } + + // Steer response: assert it was accepted in the live run. + if steer_id >= 0 && v["id"] == json!(steer_id) { + assert!( + v.get("result").is_some(), + "steer must be accepted while the run is active; got: {v}" + ); + assert_eq!( + v["result"]["runId"].as_str(), + run_id.as_deref(), + "steer must reference the live run id" + ); + steer_accepted = true; + continue; + } + + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!( + v.get("result").is_some(), + "turn 2 must succeed even when cap blocks round-1 handoff; got: {v}" + ); + break; + } + } + + assert!( + steer_accepted, + "steer was never accepted during turn 2; the steer arm is missing coverage" + ); + + // 4 LLM requests: seed + summarize + tool-call-with-usage + final-complete. + let count = llm.captured.lock().await.len(); + assert_eq!( + count, 4, + "expected 4 LLM requests (seed + summarize + tool-call + final); got {count}" + ); + + let stderr = h.stderr_text(); + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN in stderr; got: {stderr}" + ); + assert!( + stderr.contains("reason=\"preflight\""), + "expected reason=\"preflight\" field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("handoff_attempts="), + "expected handoff_attempts field in cap WARN; got: {stderr}" + ); + assert!( + stderr.contains("max_handoffs="), + "expected max_handoffs field in cap WARN; got: {stderr}" + ); + + h.shutdown().await; +} + +/// A failing `summarize()` call must still consume one slot from the per-turn +/// handoff-attempt budget. Before the fix, `handoff_count` was incremented only +/// on a successful compaction; a flaky summarizer could be retried indefinitely +/// within a turn. The fix moves the increment to before `summarize()`. +/// +/// Proof: with `max_handoffs=1` and a multi-round turn: +/// - Round 0 preflight: threshold met, attempts: 0→1, summarize() fails → Skipped. +/// - Round 1 preflight: attempts=1 >= cap=1 → WARN (cap hit despite no successful +/// compaction). Without the pre-summarize increment, attempts would still be 0 +/// here and a second summarize() would be attempted — the bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn failed_summarize_burns_handoff_attempt_budget() { + // We need the summarize() call to fail. The summarize path uses the same + // fake LLM server; we queue an HTTP error body for the summarize request. + // But our spawn_capturing_llm always returns 200, so we use a non-OpenAI- + // shaped response that the agent will treat as an error (missing `choices`). + // + // LLM call sequence: + // req 1: turn 1 complete() → usage=950 (seeds the gate) + // req 2: turn 2 round 0 summarize() → malformed response (treated as error) + // handoff_attempts incremented to 1 BEFORE this + // req 3: turn 2 round 0 complete() → tool_call + usage=950 (re-arms gate) + // req 4: turn 2 round 1 preflight → cap reached: WARN (attempts=1 >= max=1) + // req 5: turn 2 round 1 complete() → end_turn + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let bad_summary_response = json!({ "error": "upstream unavailable" }); // no `choices` + let tool_call_with_usage = { + let mut v = openai_tool_call("tc-2", "test_tool", json!({})); + v["usage"] = json!({ + "prompt_tokens": 950u64, + "completion_tokens": 5, + "total_tokens": 955, + }); + v + }; + let llm = spawn_capturing_llm(vec![ + openai_text_with_usage("seed", 950), // turn 1: seed usage + bad_summary_response, // turn 2 round 0: summarize fails + tool_call_with_usage, // turn 2 round 0: complete → tool call + openai_text_with_usage("done", 10), // turn 2 round 1: final answer + ]) + .await; + + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_MAX_CONTEXT_TOKENS", "1000"), + ("BUZZ_AGENT_MAX_OUTPUT_TOKENS", "100"), + ("BUZZ_AGENT_MAX_HANDOFFS", "1"), + ( + "BUZZ_AGENT_MAX_HISTORY_BYTES", + &(16 * 1024 * 1024).to_string(), + ), + ], + ) + .await; + + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "budget_test", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + // Turn 1: seed high usage. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"seed"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Turn 2: round 0 summarize fails, but attempts was already incremented. + // Round 1 preflight must see cap hit and emit WARN. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"work"}]}), + ) + .await; + + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v["id"] == json!(p2) { + assert!(v.get("result").is_some(), "turn 2 must succeed; got: {v}"); + break; + } + } + + let stderr = h.stderr_text(); + // Round 0: the failed summarize should warn about the failure. + assert!( + stderr.contains("handoff failed") || stderr.contains("handoff returned empty"), + "expected summarize-failure WARN; got: {stderr}" + ); + // Round 1: cap must be hit (attempts=1 from the failed attempt). + assert!( + stderr.contains("handoff cap reached"), + "expected cap-reached WARN after failed summarize burned the attempt; got: {stderr}" + ); + + h.shutdown().await; +} diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 4e3fe2a7fe..4c49bec9e3 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -108,6 +108,15 @@ pub const KIND_EVENT_REMINDER: u32 = 30300; /// dedicated push lease tables. pub const KIND_PUSH_LEASE: u32 = 30350; +/// NIP-PMA: owner-encrypted private managed-agent aggregate. +/// +/// Addressed by `(owner pubkey, kind, agent pubkey)`. The signed outer tags +/// expose only the agent coordinate, CAS generation/predecessor, and active/deleted +/// state required for relay enforcement. Content is NIP-44 v2 encrypted from +/// the owner's key to itself and contains the runnable identity/configuration +/// plus exact public projection bindings. See `docs/nips/NIP-PMA.md`. +pub const KIND_PRIVATE_MANAGED_AGENT: u32 = 30179; + /// Kinds whose stored events are readable only by their author. /// /// The relay must never reveal the existence, count, tags, content, schedule, @@ -117,7 +126,11 @@ pub const KIND_PUSH_LEASE: u32 = 30350; /// /// Currently a tiny linear set. If this grows past ~4 kinds, convert to a /// compile-time bitset or sorted array with binary search for hot-path use. -pub const AUTHOR_ONLY_KINDS: &[u32] = &[KIND_EVENT_REMINDER, KIND_PUSH_LEASE]; +pub const AUTHOR_ONLY_KINDS: &[u32] = &[ + KIND_EVENT_REMINDER, + KIND_PUSH_LEASE, + KIND_PRIVATE_MANAGED_AGENT, +]; /// Kinds that require a result-level read gate beyond the filter-layer /// `#p` check: even a reader who knows an event id MUST match the event's @@ -700,6 +713,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_TEAM, KIND_MANAGED_AGENT, KIND_TEAM_CATALOG, + KIND_PRIVATE_MANAGED_AGENT, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -903,6 +917,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM)); // 30176 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_MANAGED_AGENT)); // 30177 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 ∈ 30000–39999 +const _: () = assert!(is_parameterized_replaceable(KIND_PRIVATE_MANAGED_AGENT)); // 30179 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_EVENT_REMINDER)); // 30300 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_DM_VISIBILITY)); // 30622 ∈ 30000–39999 diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 7ec49a3c5a..5b7cca1ad6 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -34,6 +34,8 @@ pub mod outside_execution; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// NIP-PMA owner-encrypted private managed-agent wire codec. +pub mod private_managed_agent; /// Canonical relay runtime identities. pub mod relay; /// Wire format for sponsorship requests and results. diff --git a/crates/buzz-core/src/private_managed_agent.rs b/crates/buzz-core/src/private_managed_agent.rs new file mode 100644 index 0000000000..180dd6fa0c --- /dev/null +++ b/crates/buzz-core/src/private_managed_agent.rs @@ -0,0 +1,1134 @@ +//! NIP-PMA private managed-agent wire codec. +//! +//! This module defines and validates the inert wire format only. Relays must +//! not accept [`KIND_PRIVATE_MANAGED_AGENT`](crate::kind::KIND_PRIVATE_MANAGED_AGENT) +//! until the dedicated privacy and aggregate-CAS transactions are deployed. + +use std::collections::{BTreeMap, HashSet}; +use std::fmt; +use std::str::FromStr; + +use nostr::nips::nip44::{self, Version}; +use nostr::secp256k1::schnorr::Signature; +use nostr::secp256k1::Message; +use nostr::{Event, EventBuilder, EventId, Keys, Kind, PublicKey, Tag, SECP256K1}; +use serde::de::{DeserializeSeed, Deserializer, MapAccess, SeqAccess, Visitor}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::kind::{KIND_MANAGED_AGENT, KIND_PERSONA, KIND_PRIVATE_MANAGED_AGENT}; + +/// Wire-format discriminator for decrypted private managed-agent payloads. +pub const FORMAT: &str = "buzz-private-managed-agent"; +/// Current decrypted payload schema version. +pub const VERSION: u32 = 1; +/// NIP-44 v2 plaintext limit. +pub const MAX_PLAINTEXT_BYTES: usize = 65_535; +/// Maximum plausible NIP-44 v2 ciphertext length. +pub const MAX_CIPHERTEXT_BYTES: usize = 87_472; +/// Largest integer represented exactly by interoperable JSON implementations. +pub const MAX_SAFE_GENERATION: u64 = (1_u64 << 53) - 1; +/// Maximum number of environment variables in one private payload. +pub const MAX_ENV_VARS: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable key. +pub const MAX_ENV_KEY_BYTES: usize = 256; +/// Maximum UTF-8 bytes in one environment-variable value. +pub const MAX_ENV_VALUE_BYTES: usize = 16_384; +/// Maximum number of explicit agent arguments. +pub const MAX_AGENT_ARGS: usize = 256; +/// Maximum UTF-8 bytes in one argument. +pub const MAX_AGENT_ARG_BYTES: usize = 8_192; +/// Maximum serialized bytes accepted for an extension/recovery/config value. +pub const MAX_VALUE_BYTES: usize = 32_768; + +/// Errors returned by the private managed-agent codec. +#[derive(Debug, Error, PartialEq, Eq)] +pub enum Error { + /// The signed outer event is malformed or does not match the expected owner. + #[error("invalid private managed-agent envelope: {0}")] + InvalidEnvelope(String), + /// The ciphertext could not be authenticated/decrypted. Deliberately redacted. + #[error("private managed-agent payload could not be decrypted")] + Decrypt, + /// The decrypted JSON is malformed, ambiguous, or semantically invalid. + #[error("invalid private managed-agent payload: {0}")] + InvalidPayload(String), + /// Encryption failed. + #[error("private managed-agent encryption failed")] + Encrypt, + /// Event signing failed. + #[error("private managed-agent signing failed")] + Sign, +} + +/// Authoritative lifecycle state repeated in the outer tags and ciphertext. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum State { + /// Runnable aggregate. + Active, + /// Anti-resurrection tombstone. + Deleted, +} + +impl State { + fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Deleted => "deleted", + } + } +} + +/// Versioned signed-event recovery material for a bound public projection. +/// +/// Retaining the complete signed event makes reconstruction unambiguous: its +/// signature, ID, author, kind, coordinate, and exact content bytes can all be +/// checked without trusting replaceable-event history. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectionRecoveryV1 { + /// Recovery schema version. Version 1 stores one complete signed event. + pub version: u32, + /// Exact signed public projection event. + pub signed_event: Event, +} + +/// Complete definition projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DefinitionBinding { + /// CAS-managed definition revision pinned by this aggregate. + pub revision: u64, + /// Exact signed kind:30175 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Complete kind:30177 projection binding and recovery material. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstanceBinding { + /// Exact signed kind:30177 event ID. + pub event_id: String, + /// Lowercase SHA-256 of the exact projection content bytes. + pub content_sha256: String, + /// Versioned signed event sufficient to reproduce the projection. + pub recovery: ProjectionRecoveryV1, +} + +/// Secret agent identity material. It never appears in public projections. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateIdentity { + /// Agent private key in nsec form. + pub private_key_nsec: String, + /// Optional NIP-OA owner attestation JSON. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth_tag: Option, +} + +impl fmt::Debug for PrivateIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateIdentity") + .field("private_key_nsec", &"") + .field("auth_tag", &self.auth_tag.as_ref().map(|_| "")) + .finish() + } +} + +/// Portable private runnable configuration. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PrivateConfig { + /// Explicit kind:30175 coordinate, when definition-backed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub definition_coordinate: Option, + /// Intended relay endpoint; validated again on each device before use. + pub relay_url: String, + /// Explicit harness override; never launched without local validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_command_override: Option, + /// Explicit harness arguments; validated again on each device. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub agent_args: Vec, + /// Idle timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub idle_timeout_seconds: Option, + /// Absolute turn timeout in seconds. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_turn_duration_seconds: Option, + /// Secret environment overrides. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub env_vars: BTreeMap, + /// Versioned backend configuration. Device/provider validation is required. + pub backend: Value, + /// Durable remote backend identity; ownership/existence is device-validated. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend_agent_id: Option, + /// Portable team linkage. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub team_id: Option, + /// Portable identity within a team. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub persona_name_in_team: Option, + /// Versioned provider/definition relay-mesh marker. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub relay_mesh: Option, +} + +impl fmt::Debug for PrivateConfig { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivateConfig") + .field("contents", &"") + .finish() + } +} + +/// Fields present only when [`Payload::state`] is [`State::Active`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ActivePayload { + /// Exact definition projection binding. + pub definition: DefinitionBinding, + /// Exact public instance projection binding. + pub instance_projection: InstanceBinding, + /// Secret identity material. + pub identity: PrivateIdentity, + /// Private portable/device-validated configuration. + pub config: PrivateConfig, +} + +/// Decrypted private managed-agent payload. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Payload { + /// Always [`FORMAT`]. + pub format: String, + /// Always [`VERSION`]. + pub version: u32, + /// Agent pubkey and event `d` coordinate. + pub agent_pubkey: String, + /// Owner pubkey and signed event author. + pub owner_pubkey: String, + /// Monotonic CAS generation. + pub generation: u64, + /// Exact predecessor event ID; absent only for generation one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_event_id: Option, + /// Lifecycle state, repeated in the outer `state` tag. + pub state: State, + /// RFC3339 bookkeeping timestamp; never used for conflict resolution. + pub updated_at: String, + /// Required for active records and forbidden for tombstones. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub active: Option, + /// Required for tombstones and forbidden for active records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + /// Forward-compatible namespaced data. Core semantics must never depend on it. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub extensions: BTreeMap, +} + +/// Validated public metadata from a private managed-agent event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Envelope { + /// Agent pubkey from `d`. + pub agent_pubkey: PublicKey, + /// Owner pubkey from the signed event author. + pub owner_pubkey: PublicKey, + /// CAS generation from `g`. + pub generation: u64, + /// CAS predecessor from `prev`. + pub previous_event_id: Option, + /// Lifecycle state from `state`. + pub state: State, +} + +/// Compute the lowercase SHA-256 binding for exact projection content bytes. +pub fn content_sha256(content: &[u8]) -> String { + hex::encode(Sha256::digest(content)) +} + +/// Validate a signed outer envelope before any decryption. +pub fn validate_envelope(event: &Event, expected_owner: &PublicKey) -> Result { + if event.kind.as_u16() as u32 != KIND_PRIVATE_MANAGED_AGENT { + return Err(Error::InvalidEnvelope("wrong kind".into())); + } + if &event.pubkey != expected_owner { + return Err(Error::InvalidEnvelope( + "author is not expected owner".into(), + )); + } + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidEnvelope( + "invalid event id or signature".into(), + )); + } + if event.content.is_empty() || event.content.len() > MAX_CIPHERTEXT_BYTES { + return Err(Error::InvalidEnvelope("invalid ciphertext length".into())); + } + + let mut d = None; + let mut g = None; + let mut prev = None; + let mut state = None; + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.len() != 2 { + return Err(Error::InvalidEnvelope( + "every tag must have exactly one value".into(), + )); + } + let slot = match parts[0].as_str() { + "d" => &mut d, + "g" => &mut g, + "prev" => &mut prev, + "state" => &mut state, + name => return Err(Error::InvalidEnvelope(format!("unexpected tag: {name}"))), + }; + if slot.replace(parts[1].clone()).is_some() { + return Err(Error::InvalidEnvelope(format!( + "duplicate {} tag", + parts[0] + ))); + } + } + + let agent_pubkey = parse_canonical_pubkey( + "d", + d.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing d tag".into()))?, + )?; + let owner_pubkey = *expected_owner; + let generation = parse_generation( + g.as_deref() + .ok_or_else(|| Error::InvalidEnvelope("missing g tag".into()))?, + )?; + let previous_event_id = match prev { + Some(value) => Some(parse_event_id("prev", &value)?), + None => None, + }; + if (generation == 1) != previous_event_id.is_none() { + return Err(Error::InvalidEnvelope( + "prev must be absent exactly at generation 1".into(), + )); + } + let state = match state.as_deref() { + Some("active") => State::Active, + Some("deleted") => State::Deleted, + Some(_) => return Err(Error::InvalidEnvelope("invalid state tag".into())), + None => return Err(Error::InvalidEnvelope("missing state tag".into())), + }; + Ok(Envelope { + agent_pubkey, + owner_pubkey, + generation, + previous_event_id, + state, + }) +} + +/// Encrypt and sign an inert private managed-agent event candidate. +pub fn build_event(owner_keys: &Keys, payload: &Payload, created_at: u64) -> Result { + validate_payload(payload)?; + if payload.owner_pubkey != owner_keys.public_key().to_hex() { + return Err(Error::InvalidPayload( + "owner_pubkey does not match signing key".into(), + )); + } + let plaintext = serde_json::to_vec(payload).map_err(|_| Error::Encrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::InvalidPayload( + "plaintext exceeds NIP-44 limit".into(), + )); + } + let plaintext = std::str::from_utf8(&plaintext).map_err(|_| Error::Encrypt)?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + plaintext, + Version::V2, + ) + .map_err(|_| Error::Encrypt)?; + let mut tags = vec![ + parse_tag(["d", payload.agent_pubkey.as_str()])?, + parse_tag(["g", payload.generation.to_string().as_str()])?, + parse_tag(["state", payload.state.as_str()])?, + ]; + if let Some(previous) = payload.previous_event_id.as_deref() { + tags.push(parse_tag(["prev", previous])?); + } + EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(tags) + .custom_created_at(nostr::Timestamp::from(created_at)) + .sign_with_keys(owner_keys) + .map_err(|_| Error::Sign) +} + +/// Validate, owner-self decrypt, strictly parse, and cross-check a payload. +pub fn validate_and_decrypt( + event: &Event, + owner_keys: &Keys, +) -> Result<(Envelope, Payload), Error> { + let envelope = validate_envelope(event, &owner_keys.public_key())?; + let plaintext = nip44::decrypt( + owner_keys.secret_key(), + &owner_keys.public_key(), + &event.content, + ) + .map_err(|_| Error::Decrypt)?; + if plaintext.len() > MAX_PLAINTEXT_BYTES { + return Err(Error::Decrypt); + } + let value = parse_strict_json(plaintext.as_bytes())?; + let payload: Payload = + serde_json::from_value(value).map_err(|e| Error::InvalidPayload(format!("schema: {e}")))?; + validate_payload(&payload)?; + if payload.agent_pubkey != envelope.agent_pubkey.to_hex() + || payload.owner_pubkey != envelope.owner_pubkey.to_hex() + || payload.generation != envelope.generation + || payload.state != envelope.state + || payload.previous_event_id.as_deref() + != envelope + .previous_event_id + .as_ref() + .map(EventId::to_hex) + .as_deref() + { + return Err(Error::InvalidPayload( + "outer/inner metadata mismatch".into(), + )); + } + Ok((envelope, payload)) +} + +/// Validate decrypted payload semantics independently of encryption. +pub fn validate_payload(payload: &Payload) -> Result<(), Error> { + if payload.format != FORMAT || payload.version != VERSION { + return Err(Error::InvalidPayload( + "unsupported format or version".into(), + )); + } + let agent = parse_canonical_pubkey("agent_pubkey", &payload.agent_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_canonical_pubkey("owner_pubkey", &payload.owner_pubkey) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + validate_generation_and_prev(payload.generation, payload.previous_event_id.as_deref())?; + parse_rfc3339("updated_at", &payload.updated_at)?; + for (key, value) in &payload.extensions { + if key.is_empty() || key.len() > 128 || !key.contains(':') { + return Err(Error::InvalidPayload( + "extension keys must be non-empty namespaced strings <= 128 bytes".into(), + )); + } + validate_value_size("extension", value)?; + } + match payload.state { + State::Active => { + if payload.deleted_at.is_some() { + return Err(Error::InvalidPayload( + "active payload must not contain deleted_at".into(), + )); + } + let active = payload.active.as_ref().ok_or_else(|| { + Error::InvalidPayload("active payload missing active body".into()) + })?; + validate_active(active, &agent, &payload.owner_pubkey)?; + } + State::Deleted => { + if payload.active.is_some() { + return Err(Error::InvalidPayload( + "deleted payload must not contain active body".into(), + )); + } + parse_rfc3339( + "deleted_at", + payload.deleted_at.as_deref().ok_or_else(|| { + Error::InvalidPayload("deleted payload missing deleted_at".into()) + })?, + )?; + } + } + Ok(()) +} + +fn validate_active( + active: &ActivePayload, + agent: &PublicKey, + owner_pubkey: &str, +) -> Result<(), Error> { + if active.definition.revision == 0 || active.definition.revision > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload("invalid definition revision".into())); + } + let definition_d = + parse_definition_coordinate(active.config.definition_coordinate.as_deref(), owner_pubkey)?; + validate_binding( + "definition", + KIND_PERSONA, + owner_pubkey, + Some(&definition_d), + &active.definition.event_id, + &active.definition.content_sha256, + &active.definition.recovery, + )?; + validate_binding( + "instance_projection", + KIND_MANAGED_AGENT, + owner_pubkey, + Some(&agent.to_hex()), + &active.instance_projection.event_id, + &active.instance_projection.content_sha256, + &active.instance_projection.recovery, + )?; + let agent_keys = Keys::parse(active.identity.private_key_nsec.trim()) + .map_err(|_| Error::InvalidPayload("invalid agent nsec".into()))?; + if agent_keys.public_key() != *agent { + return Err(Error::InvalidPayload( + "agent nsec does not derive agent_pubkey".into(), + )); + } + if let Some(auth_tag) = &active.identity.auth_tag { + validate_auth_tag(auth_tag, owner_pubkey, agent)?; + } + let config = &active.config; + if config.relay_url.is_empty() || config.relay_url.len() > 4096 { + return Err(Error::InvalidPayload("invalid relay_url length".into())); + } + if config.agent_args.len() > MAX_AGENT_ARGS + || config + .agent_args + .iter() + .any(|arg| arg.len() > MAX_AGENT_ARG_BYTES) + { + return Err(Error::InvalidPayload("agent_args exceed limits".into())); + } + if config.env_vars.len() > MAX_ENV_VARS + || config.env_vars.iter().any(|(k, v)| { + k.is_empty() || k.len() > MAX_ENV_KEY_BYTES || v.len() > MAX_ENV_VALUE_BYTES + }) + { + return Err(Error::InvalidPayload("env_vars exceed limits".into())); + } + validate_value_size("backend", &config.backend)?; + if let Some(mesh) = &config.relay_mesh { + validate_value_size("relay_mesh", mesh)?; + } + Ok(()) +} + +fn validate_auth_tag(auth_tag: &str, expected_owner: &str, agent: &PublicKey) -> Result<(), Error> { + if auth_tag.is_empty() || auth_tag.len() > 4096 { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let parts: Vec = serde_json::from_str(auth_tag) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if parts.len() != 4 || parts[0] != "auth" || parts[1] != expected_owner || !parts[2].is_empty() + { + return Err(Error::InvalidPayload( + "auth_tag must be an unconditional attestation for this owner".into(), + )); + } + parse_canonical_pubkey("auth_tag owner", &parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + if agent.to_hex() == expected_owner { + return Err(Error::InvalidPayload( + "auth_tag must attest a distinct agent key".into(), + )); + } + if parts[3].len() != 128 + || !parts[3] + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidPayload("invalid auth_tag".into())); + } + let signature = Signature::from_str(&parts[3]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let preimage = format!("nostr:agent-auth:{}:", agent.to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let message = Message::from_digest(digest.into()); + let owner = PublicKey::from_hex(&parts[1]) + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + let owner = owner + .xonly() + .map_err(|_| Error::InvalidPayload("invalid auth_tag".into()))?; + SECP256K1 + .verify_schnorr(&signature, &message, &owner) + .map_err(|_| Error::InvalidPayload("invalid auth_tag signature".into())) +} + +fn parse_definition_coordinate( + coordinate: Option<&str>, + owner_pubkey: &str, +) -> Result { + let coordinate = coordinate.ok_or_else(|| { + Error::InvalidPayload("active payload missing definition_coordinate".into()) + })?; + let mut parts = coordinate.splitn(3, ':'); + let kind = parts.next(); + let owner = parts.next(); + let d = parts.next(); + if kind != Some("30175") || owner != Some(owner_pubkey) || d.is_none_or(str::is_empty) { + return Err(Error::InvalidPayload( + "definition_coordinate must be 30175::".into(), + )); + } + Ok(d.unwrap().to_owned()) +} + +fn validate_binding( + label: &str, + expected_kind: u32, + owner_pubkey: &str, + expected_d: Option<&str>, + event_id: &str, + hash: &str, + recovery: &ProjectionRecoveryV1, +) -> Result<(), Error> { + parse_event_id(label, event_id).map_err(|e| Error::InvalidPayload(e.to_string()))?; + parse_lower_hex_32(&format!("{label}.content_sha256"), hash) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + if recovery.version != 1 { + return Err(Error::InvalidPayload(format!( + "unsupported {label} recovery version" + ))); + } + let event = &recovery.signed_event; + if !event.verify_id() || !event.verify_signature() { + return Err(Error::InvalidPayload(format!( + "invalid {label} recovery event" + ))); + } + if event.id.to_hex() != event_id + || event.kind.as_u16() as u32 != expected_kind + || event.pubkey.to_hex() != owner_pubkey + || content_sha256(event.content.as_bytes()) != hash + { + return Err(Error::InvalidPayload(format!( + "{label} recovery does not match binding" + ))); + } + let d_tags: Vec<_> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(String::as_str) == Some("d")).then_some(parts) + }) + .collect(); + if d_tags.len() != 1 || d_tags[0].len() != 2 || d_tags[0][1].is_empty() { + return Err(Error::InvalidPayload(format!( + "{label} recovery must have exactly one non-empty d tag" + ))); + } + if expected_d.is_some_and(|expected| d_tags[0][1] != expected) { + return Err(Error::InvalidPayload(format!( + "{label} recovery has wrong coordinate" + ))); + } + validate_value_size( + label, + &serde_json::to_value(recovery) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))?, + ) +} + +fn validate_generation_and_prev(generation: u64, previous: Option<&str>) -> Result<(), Error> { + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidPayload( + "generation must be a positive safe integer".into(), + )); + } + if (generation == 1) != previous.is_none() { + return Err(Error::InvalidPayload( + "previous_event_id must be absent exactly at generation 1".into(), + )); + } + if let Some(value) = previous { + parse_event_id("previous_event_id", value) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + } + Ok(()) +} + +fn validate_value_size(label: &str, value: &Value) -> Result<(), Error> { + let len = serde_json::to_vec(value) + .map_err(|_| Error::InvalidPayload(format!("invalid {label}")))? + .len(); + if len > MAX_VALUE_BYTES { + return Err(Error::InvalidPayload(format!("{label} exceeds size limit"))); + } + Ok(()) +} + +fn parse_rfc3339(label: &str, value: &str) -> Result<(), Error> { + chrono::DateTime::parse_from_rfc3339(value) + .map(|_| ()) + .map_err(|_| Error::InvalidPayload(format!("{label} must be RFC3339"))) +} + +fn parse_generation(value: &str) -> Result { + if value.is_empty() + || (value.len() > 1 && value.starts_with('0')) + || !value.bytes().all(|b| b.is_ascii_digit()) + { + return Err(Error::InvalidEnvelope("g must be canonical decimal".into())); + } + let generation = value + .parse::() + .map_err(|_| Error::InvalidEnvelope("invalid g tag".into()))?; + if generation == 0 || generation > MAX_SAFE_GENERATION { + return Err(Error::InvalidEnvelope( + "g must be a positive safe integer".into(), + )); + } + Ok(generation) +} + +fn parse_canonical_pubkey(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + let key = PublicKey::from_hex(value) + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label}")))?; + key.xonly() + .map_err(|_| Error::InvalidEnvelope(format!("invalid {label} curve point")))?; + Ok(key) +} + +fn parse_event_id(label: &str, value: &str) -> Result { + parse_lower_hex_32(label, value)?; + EventId::from_hex(value).map_err(|_| Error::InvalidEnvelope(format!("invalid {label}"))) +} + +fn parse_lower_hex_32(label: &str, value: &str) -> Result<(), Error> { + if value.len() != 64 + || !value + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::InvalidEnvelope(format!( + "{label} must be 64 lowercase hex chars" + ))); + } + Ok(()) +} + +fn parse_tag(parts: [&str; N]) -> Result { + Tag::parse(parts).map_err(|_| Error::InvalidEnvelope("failed to build tag".into())) +} + +fn parse_strict_json(bytes: &[u8]) -> Result { + struct StrictValue; + impl<'de> DeserializeSeed<'de> for StrictValue { + type Value = Value; + fn deserialize>(self, d: D) -> Result { + d.deserialize_any(self) + } + } + impl<'de> Visitor<'de> for StrictValue { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + f.write_str("valid JSON with unique object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(Value::Bool(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(Value::Number(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("non-finite float")) + } + fn visit_str(self, v: &str) -> Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> Result { + Ok(Value::String(v)) + } + fn visit_unit(self) -> Result { + Ok(Value::Null) + } + fn visit_none(self) -> Result { + Ok(Value::Null) + } + fn visit_some>(self, d: D) -> Result { + d.deserialize_any(self) + } + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(value) = seq.next_element_seed(StrictValue)? { + out.push(value); + } + Ok(Value::Array(out)) + } + fn visit_map>(self, mut map: A) -> Result { + let mut seen = HashSet::new(); + let mut out = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if !seen.insert(key.clone()) { + return Err(serde::de::Error::custom(format!("duplicate key: {key}"))); + } + out.insert(key, map.next_value_seed(StrictValue)?); + } + Ok(Value::Object(out)) + } + } + let mut deserializer = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue + .deserialize(&mut deserializer) + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + deserializer + .end() + .map_err(|e| Error::InvalidPayload(e.to_string()))?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::ToBech32; + + fn auth_tag(owner: &Keys, agent: &Keys) -> String { + let preimage = format!("nostr:agent-auth:{}:", agent.public_key().to_hex()); + let digest = Sha256::digest(preimage.as_bytes()); + let signature = owner.sign_schnorr(&Message::from_digest(digest.into())); + serde_json::json!([ + "auth", + owner.public_key().to_hex(), + "", + signature.to_string() + ]) + .to_string() + } + + fn payload(owner: &Keys, agent: &Keys) -> Payload { + let definition_event = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + let instance_event = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "instance") + .tags(vec![Tag::parse([ + "d", + agent.public_key().to_hex().as_str(), + ]) + .unwrap()]) + .custom_created_at(nostr::Timestamp::from(1_785_780_000)) + .sign_with_keys(owner) + .unwrap(); + Payload { + format: FORMAT.into(), + version: VERSION, + agent_pubkey: agent.public_key().to_hex(), + owner_pubkey: owner.public_key().to_hex(), + generation: 1, + previous_event_id: None, + state: State::Active, + updated_at: "2026-08-03T18:00:00Z".into(), + active: Some(ActivePayload { + definition: DefinitionBinding { + revision: 1, + event_id: definition_event.id.to_hex(), + content_sha256: content_sha256(definition_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: definition_event, + }, + }, + instance_projection: InstanceBinding { + event_id: instance_event.id.to_hex(), + content_sha256: content_sha256(instance_event.content.as_bytes()), + recovery: ProjectionRecoveryV1 { + version: 1, + signed_event: instance_event, + }, + }, + identity: PrivateIdentity { + private_key_nsec: agent.secret_key().to_bech32().unwrap(), + auth_tag: None, + }, + config: PrivateConfig { + definition_coordinate: Some(format!( + "30175:{}:test-agent", + owner.public_key().to_hex() + )), + relay_url: "wss://relay.example".into(), + agent_command_override: None, + agent_args: vec![], + idle_timeout_seconds: Some(300), + max_turn_duration_seconds: None, + env_vars: BTreeMap::from([("SECRET".into(), "not-public".into())]), + backend: serde_json::json!({"type": "local"}), + backend_agent_id: None, + team_id: None, + persona_name_in_team: None, + relay_mesh: None, + }, + }), + deleted_at: None, + extensions: BTreeMap::new(), + } + } + + #[test] + fn owner_self_round_trip_binds_outer_and_inner() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let expected = payload(&owner, &agent); + let event = build_event(&owner, &expected, 1_785_780_000).unwrap(); + let (envelope, actual) = validate_and_decrypt(&event, &owner).unwrap(); + assert_eq!(actual, expected); + assert_eq!(envelope.agent_pubkey, agent.public_key()); + assert_eq!(envelope.owner_pubkey, owner.public_key()); + assert_eq!(envelope.generation, 1); + assert_eq!(envelope.state, State::Active); + } + + #[test] + fn debug_output_redacts_private_material() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let private_key_nsec = candidate + .active + .as_ref() + .unwrap() + .identity + .private_key_nsec + .clone(); + let active = candidate.active.as_mut().unwrap(); + active.identity.auth_tag = Some("secret-auth-tag".into()); + active.config.backend = serde_json::json!({"token": "secret-backend-token"}); + + let debug = format!("{candidate:?}"); + assert!(debug.contains("")); + assert!(!debug.contains(&private_key_nsec)); + assert!(!debug.contains("secret-auth-tag")); + assert!(!debug.contains("not-public")); + assert!(!debug.contains("secret-backend-token")); + } + + #[test] + fn wrong_owner_and_tampering_fail_closed() { + let owner = Keys::generate(); + let event = + build_event(&owner, &payload(&owner, &Keys::generate()), 1_785_780_000).unwrap(); + let stranger = Keys::generate(); + assert!(matches!( + validate_and_decrypt(&event, &stranger), + Err(Error::InvalidEnvelope(_)) + )); + + let mut tampered = event; + tampered.content.push('A'); + assert!(matches!( + validate_and_decrypt(&tampered, &owner), + Err(Error::InvalidEnvelope(_)) + )); + } + + #[test] + fn duplicate_and_unknown_json_fields_are_rejected() { + let duplicate = br#"{"format":"a","format":"b"}"#; + assert!(matches!( + parse_strict_json(duplicate), + Err(Error::InvalidPayload(message)) if message.contains("duplicate key") + )); + + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut value = serde_json::to_value(payload(&owner, &agent)).unwrap(); + value + .as_object_mut() + .unwrap() + .insert("surprise".into(), Value::Bool(true)); + let err = serde_json::from_value::(value).unwrap_err(); + assert!(err.to_string().contains("unknown field")); + } + + #[test] + fn auth_tag_must_be_unconditional_and_bound_to_owner_and_agent() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + candidate.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &agent)); + validate_payload(&candidate).unwrap(); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&Keys::generate(), &agent)); + assert!(validate_payload(&candidate).is_err()); + + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(auth_tag(&owner, &Keys::generate())); + assert!(validate_payload(&candidate).is_err()); + + let mut self_attested = payload(&owner, &owner); + self_attested.active.as_mut().unwrap().identity.auth_tag = Some(auth_tag(&owner, &owner)); + assert!(matches!( + validate_payload(&self_attested), + Err(Error::InvalidPayload(message)) if message.contains("distinct agent key") + )); + + let valid = auth_tag(&owner, &agent); + let mut parts: Vec = serde_json::from_str(&valid).unwrap(); + parts[2] = "kind=9".into(); + candidate.active.as_mut().unwrap().identity.auth_tag = + Some(serde_json::to_string(&parts).unwrap()); + assert!(validate_payload(&candidate).is_err()); + } + + #[test] + fn active_identity_must_derive_coordinate() { + let owner = Keys::generate(); + let mut candidate = payload(&owner, &Keys::generate()); + candidate.active.as_mut().unwrap().identity.private_key_nsec = + Keys::generate().secret_key().to_bech32().unwrap(); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not derive") + )); + } + + #[test] + fn tombstone_requires_successor_shape() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut deleted = payload(&owner, &agent); + deleted.generation = 2; + deleted.previous_event_id = Some("33".repeat(32)); + deleted.state = State::Deleted; + deleted.active = None; + deleted.deleted_at = Some("2026-08-03T18:01:00Z".into()); + validate_payload(&deleted).unwrap(); + + deleted.previous_event_id = None; + assert!(validate_payload(&deleted).is_err()); + } + + #[test] + fn outer_tag_grammar_rejects_duplicates_and_noncanonical_generation() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let body = payload(&owner, &agent); + let ciphertext = nip44::encrypt( + owner.secret_key(), + &owner.public_key(), + serde_json::to_string(&body).unwrap(), + Version::V2, + ) + .unwrap(); + let event = EventBuilder::new(Kind::Custom(KIND_PRIVATE_MANAGED_AGENT as u16), ciphertext) + .tags(vec![ + Tag::parse(["d", agent.public_key().to_hex().as_str()]).unwrap(), + Tag::parse(["g", "01"]).unwrap(), + Tag::parse(["state", "active"]).unwrap(), + ]) + .sign_with_keys(&owner) + .unwrap(); + assert!(matches!( + validate_envelope(&event, &owner.public_key()), + Err(Error::InvalidEnvelope(message)) if message.contains("canonical decimal") + )); + } + + #[test] + fn projection_recovery_must_match_binding_and_coordinate() { + let owner = Keys::generate(); + let agent = Keys::generate(); + let mut candidate = payload(&owner, &agent); + let active = candidate.active.as_mut().unwrap(); + active.instance_projection.content_sha256 = content_sha256(b"wrong"); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .config + .definition_coordinate = + Some(format!("30175:{}:wrong-slug", owner.public_key().to_hex())); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("wrong coordinate") + )); + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .version = 2; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("unsupported definition recovery version") + )); + + let mut candidate = payload(&owner, &agent); + candidate + .active + .as_mut() + .unwrap() + .definition + .recovery + .signed_event + .content + .push('!'); + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("invalid definition recovery event") + )); + + let mut candidate = payload(&owner, &agent); + let wrong_kind = EventBuilder::new(Kind::Custom(KIND_MANAGED_AGENT as u16), "definition") + .tags(vec![Tag::parse(["d", "test-agent"]).unwrap()]) + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = wrong_kind.id.to_hex(); + definition.content_sha256 = content_sha256(wrong_kind.content.as_bytes()); + definition.recovery.signed_event = wrong_kind; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("does not match binding") + )); + + let mut candidate = payload(&owner, &agent); + let missing_d = EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "definition") + .sign_with_keys(&owner) + .unwrap(); + let definition = &mut candidate.active.as_mut().unwrap().definition; + definition.event_id = missing_d.id.to_hex(); + definition.content_sha256 = content_sha256(missing_d.content.as_bytes()); + definition.recovery.signed_event = missing_d; + assert!(matches!( + validate_payload(&candidate), + Err(Error::InvalidPayload(message)) if message.contains("exactly one non-empty d tag") + )); + } + + #[test] + fn projection_hash_fixture_is_stable() { + assert_eq!( + content_sha256(b"buzz-private-managed-agent-v1"), + "c3ca1603249c95343fc1766ba58d075d6bdf0e57b375bef38738729b2022cc80" + ); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index 5508c95cad..9d15fccfc8 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -371,7 +371,9 @@ async fn acquire_channel_membership_lock( /// Role enforcement: /// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of /// what the caller passes — callers cannot self-assign elevated roles. -/// - Private channels: requires an `invited_by` who is an active owner/admin. +/// - Private channels: requires an `invited_by` who is an active owner/admin, the channel +/// creator bootstrapping their own first membership, or the target adding themselves +/// (idempotent re-add — an active member's *role* still cannot change this way). /// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin, /// even on open channels. /// @@ -419,10 +421,14 @@ pub async fn add_member( DbError::InvalidData(format!("invalid role in database: {inviter_role_str}")) })?; - // Any member can invite others, but only owners/admins may grant elevated roles. - if role.is_elevated() && !inviter_role.is_elevated() { + // Only owners/admins may extend private-channel access to another + // identity. `inviter == pubkey` keeps a member's own idempotent + // re-add working; it is not a role-escalation hole, because the + // active-role-change guard below still rejects a self-targeted + // promotion from any non-elevated caller. + if !inviter_role.is_elevated() && inviter != pubkey { return Err(DbError::AccessDenied( - "only owners/admins may grant elevated roles".to_string(), + "only owners/admins may add private-channel members".to_string(), )); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index a670a13402..e1b45aa3a1 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -1541,7 +1541,7 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - 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 async fn setup_pool() -> PgPool { let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") @@ -1943,6 +1943,42 @@ mod tests { .expect("sign reaction event") } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn reaction_single_tx_stores_wrapped_max_shortcode() { + let pool = setup_pool().await; + let community = CommunityId::from_uuid(make_test_community(&pool).await); + let target = make_text_event("long custom emoji target"); + insert_event(&pool, community, &target, None) + .await + .expect("insert target"); + + let actor = Keys::generate(); + let emoji = format!(":{}:", "a".repeat(64)); + let reaction = make_reaction_event(&actor, &target.id.to_hex(), &emoji); + let outcome = insert_reaction_event_with_thread_metadata( + &pool, + community, + &reaction, + None, + None, + target.id.as_bytes(), + &actor.public_key().to_bytes(), + &emoji, + ) + .await + .expect("store wrapped 64-character shortcode"); + + assert!(matches!( + outcome, + ReactionEventInsertOutcome::Inserted { + was_inserted: true, + .. + } + )); + assert_eq!(emoji.chars().count(), 66); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn reaction_single_tx_duplicate_short_circuit_stores_no_event() { diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index edec3ed604..43f464526c 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,8 +561,8 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - // FORK-LOCAL PATCH (adrienlacombe/buzz): upstream ships 27 migrations; this - // fork adds two of its own, so the count is 29 here. + // FORK-LOCAL PATCH (adrienlacombe/buzz): upstream ships 28 migrations; this + // fork adds two of its own, so the count is 30 here. // // The fork's 0027 and 0028 belong to the NIP-SW wallet binding, which this // fork has since removed in favour of the Nostr key controlling the Starknet @@ -576,8 +576,10 @@ mod tests { // // Because the fork holds 0027 and 0028, upstream's own new migrations arrive // renumbered above them: upstream's `0027_channels_id_lookup_index.sql` is - // `0029_channels_id_lookup_index.sql` here. See the assertion for it below. - assert_eq!(migrations.len(), 29); + // `0029_channels_id_lookup_index.sql` here, and its + // `0028_long_reaction_payloads.sql` is `0030_long_reaction_payloads.sql`. + // See the assertions for both below. + assert_eq!(migrations.len(), 30); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -958,6 +960,16 @@ mod tests { desired_schema.contains("idx_channels_id_live"), "desired-state schema must carry the channel-id lookup index", ); + + // Long reaction payloads (upstream's 0028, FORK-LOCAL PATCH + // (adrienlacombe/buzz): renumbered to 0030 here because this fork already + // holds 0027 and 0028). + assert_eq!(migrations[29].version, 30); + let long_reactions = migrations[29].sql.as_str(); + assert!( + long_reactions.contains("ALTER TABLE reactions ALTER COLUMN emoji TYPE VARCHAR(66)") + ); + assert!(desired_schema.contains("emoji VARCHAR(66) NOT NULL")); } #[test] @@ -1200,7 +1212,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(29)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(30)); } #[tokio::test] diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index 3c5611ba09..cb3a7d1c05 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -909,18 +909,22 @@ at agent startup. ### Desktop App Import -The Buzz desktop app can import persona packs via the Import button: - -- **My Agents → Import**: Accepts `.persona.md` files (individual personas) or `.zip` files - (persona packs detected by `.plugin/plugin.json`). Pack zips are resolved in a temp directory; - each persona is previewed and imported individually into the persona library. -- **My Teams → Import**: Accepts `.zip` files (persona packs). The pack name becomes the team - name; each persona becomes a team member. - -> **Note**: The Import button parses and previews personas from the pack — it does not install the -> pack directory itself. For full pack installation (which copies the pack to -> `/agents/packs//` with re-validation), use the `install_persona_pack` -> Tauri command or a future "Install Pack" UI button. +The Buzz desktop app's **Agents** page does not import persona-pack `.zip` archives or +`.persona.md` files directly. It imports personas and teams as **snapshots** — files exported +from an agent or team that already exists inside the app: + +- **Agents section → Import**: Accepts `.agent.json` or `.agent.png` (an agent snapshot). +- **Agent teams section → Import**: Accepts `.team.json` or `.team.png` (a `buzz-team-snapshot + v1`). A persona-pack `.zip` is rejected outright with an error directing you to export a team + snapshot instead. + +> **Persona packs and desktop snapshots are two separate, non-interchangeable formats today.** +> This spec's pack format (portable, hand-authored, git-friendly) is validated and inspected via +> `buzz pack validate` / `buzz pack inspect` (Section 11). A snapshot is captured *from* an +> already-running agent or team inside the desktop app. Neither format converts into the other: +> there is no command that turns a pack into a snapshot, or a snapshot back into pack source. To +> get a pack's personas running inside the desktop app today, recreate them there by hand using +> `buzz pack inspect`'s resolved config as reference. --- diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index d3118d8a76..53e3f59463 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -224,10 +224,95 @@ impl axum::extract::FromRequestParts> for GitAuth { return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); } + deny_banned_git_principal(&state.db, tenant.community(), &pubkey, auth_tag).await?; + Ok(GitAuth { pubkey, tenant }) } } +/// Deny banned principals on every Git HTTP request. +/// +/// Git runs outside the WebSocket authentication path, so a valid NIP-98 +/// credential and channel membership are not enough — neither reflects a +/// moderation ban. Git credentials are also deliberately reused across a +/// session (see the replay notes above), so no session expiry would close the +/// gap on its own. Re-read the durable ban per request instead. +/// +/// Cascades to the proven NIP-OA owner, matching the NIP-42 gate in +/// `handlers::auth`: banning a human must also revoke their agents, or the ban +/// is bypassable by cloning and pushing through an agent key. +async fn deny_banned_git_principal( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, + auth_tag: Option<&str>, +) -> Result<(), Response> { + let agent = git_restriction_state(db, community, pubkey).await?; + + // Skip the owner read when the agent is already banned: the denial is + // identical either way. Mirrors the WebSocket cascade's short-circuit. + let owner = if agent.banned { + None + } else { + crate::api::relay_members::extract_nip_oa_owner(pubkey.as_bytes(), auth_tag) + }; + let owner_state = match owner { + Some(owner) => Some(git_restriction_state(db, community, &owner).await?), + None => None, + }; + + enforce_git_ban_cascade(&agent, owner_state.as_ref()).map_err(|status| { + warn!( + pubkey = %pubkey.to_hex(), + owner = ?owner.map(|owner| owner.to_hex()), + "git: community ban denied request" + ); + (status, "blocked: banned from this community").into_response() + }) +} + +/// One restriction read, failing closed with 503. +/// +/// A restriction-store outage must not be reported to the client as a +/// permission decision — 503 says "retry", 403 would claim a ban that was +/// never read. +async fn git_restriction_state( + db: &buzz_db::Db, + community: buzz_core::CommunityId, + pubkey: &nostr::PublicKey, +) -> Result { + db.moderation_restriction_state(community, pubkey.as_bytes()) + .await + .map_err(|error| { + warn!(pubkey = %pubkey.to_hex(), error = %error, "git: ban lookup failed closed"); + (StatusCode::SERVICE_UNAVAILABLE, "authorization unavailable").into_response() + }) +} + +fn enforce_git_ban(restriction: &buzz_db::moderation::RestrictionState) -> Result<(), StatusCode> { + if restriction.banned { + Err(StatusCode::FORBIDDEN) + } else { + Ok(()) + } +} + +/// Either principal's ban denies the request; `None` owner means no attested +/// owner to inherit from. +/// +/// Split from the DB reads so agent→owner precedence stays unit-testable +/// without Postgres. +fn enforce_git_ban_cascade( + agent: &buzz_db::moderation::RestrictionState, + owner: Option<&buzz_db::moderation::RestrictionState>, +) -> Result<(), StatusCode> { + enforce_git_ban(agent)?; + match owner { + Some(owner) => enforce_git_ban(owner), + None => Ok(()), + } +} + /// Construct the repo-root NIP-98 `u` URL expected for a git HTTP request. /// /// The host is always the server-resolved tenant host. `config_relay_url` only @@ -2610,6 +2695,76 @@ mod sec005_read_gate_tests { assert!(!read_role_allows(Some("")), "empty role must deny"); } + #[test] + fn durable_ban_denies_git_even_with_otherwise_valid_auth() { + let restriction = buzz_db::moderation::RestrictionState { + banned: true, + muted_until: None, + }; + + assert_eq!(enforce_git_ban(&restriction), Err(StatusCode::FORBIDDEN)); + } + + #[test] + fn timeout_without_ban_does_not_revoke_git_access() { + let restriction = buzz_db::moderation::RestrictionState { + banned: false, + muted_until: Some(chrono::Utc::now()), + }; + + assert_eq!(enforce_git_ban(&restriction), Ok(())); + } + + fn restriction(banned: bool) -> buzz_db::moderation::RestrictionState { + buzz_db::moderation::RestrictionState { + banned, + muted_until: None, + } + } + + // ── Agent → owner ban cascade ──────────────────────────────────────── + // + // Git accepts NIP-OA attestations on the signed NIP-98 token, so an agent + // key can act for its owner (`deny_banned_git_principal`). The NIP-42 gate + // in `handlers::auth` cascades the ban check to the proven owner for that + // reason, and Git must agree: if only the presented key were checked, a + // banned human would keep clone and push access through any agent key. + + #[test] + fn banned_owner_denies_git_for_an_otherwise_clear_agent() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(true))), + Err(StatusCode::FORBIDDEN), + "an agent must inherit its proven owner's ban" + ); + } + + #[test] + fn banned_agent_denies_git_whatever_the_owner_state() { + for owner in [None, Some(restriction(false)), Some(restriction(true))] { + assert_eq!( + enforce_git_ban_cascade(&restriction(true), owner.as_ref()), + Err(StatusCode::FORBIDDEN), + "a directly banned agent must be denied" + ); + } + } + + #[test] + fn clear_agent_and_clear_owner_allow_git() { + assert_eq!( + enforce_git_ban_cascade(&restriction(false), Some(&restriction(false))), + Ok(()) + ); + } + + #[test] + fn clear_agent_without_attested_owner_allows_git() { + // No NIP-OA tag on the request: nothing to inherit, so the agent's own + // state decides. A missing owner must not read as a ban. + assert_eq!(enforce_git_ban_cascade(&restriction(false), None), Ok(())); + } + fn announcement(keys: &Keys, tags: Vec) -> nostr::Event { EventBuilder::new(Kind::Custom(30617), "") .tags(tags) @@ -2965,4 +3120,129 @@ mod sec005_read_gate_tests { "deleted announcement must deny reads even for channel members" ); } + + // ── Ban gate wiring (requires Postgres) ────────────────────────────── + // + // The pure tests above fix the decision table; these prove the gate is + // actually wired to the durable store — that it reads the real ban row, + // resolves the NIP-OA owner from a live attestation, and fails closed when + // the store is unreachable. `deny_banned_git_principal` runs inside the + // `GitAuth` extractor, which every Git route (`info/refs`, `git-upload-pack`, + // `git-receive-pack`) goes through, so advertise, fetch and push all + // inherit these outcomes. + + /// Community + a ban actor, without the channel/repo fixture the read-gate + /// tests need — the ban gate runs before any repo is resolved. + async fn setup_ban_community() -> (buzz_db::Db, buzz_core::CommunityId, Vec) { + let db = setup_db().await; + let host = format!("ban-git-{}.example", uuid::Uuid::new_v4().simple()); + let community = db + .ensure_configured_community(&host) + .await + .expect("community") + .id; + let actor = Keys::generate().public_key().to_bytes().to_vec(); + db.ensure_user(community, &actor).await.expect("actor"); + (db, community, actor) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_denies_banned_member_and_allows_clear_member() { + let (db, community, actor) = setup_ban_community().await; + let member = Keys::generate(); + let member_pk = member.public_key().to_bytes().to_vec(); + db.ensure_user(community, &member_pk).await.expect("member"); + + assert!( + deny_banned_git_principal(&db, community, &member.public_key(), None) + .await + .is_ok(), + "precondition: an unbanned member passes the git ban gate" + ); + + db.ban_community_member(community, &member_pk, &actor, Some("test"), None) + .await + .expect("ban"); + + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &member.public_key(), None).await, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body, "blocked: banned from this community"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_cascades_to_a_banned_nip_oa_owner() { + let (db, community, actor) = setup_ban_community().await; + let owner = Keys::generate(); + let agent = Keys::generate(); + let owner_pk = owner.public_key().to_bytes().to_vec(); + let agent_pk = agent.public_key().to_bytes().to_vec(); + db.ensure_user(community, &owner_pk).await.expect("owner"); + db.ensure_user(community, &agent_pk).await.expect("agent"); + + // A real attestation: the gate must verify it, not trust a claim. + let auth_tag = buzz_sdk::nip_oa::compute_auth_tag(&owner, &agent.public_key(), "kind=9") + .expect("auth tag"); + + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)) + .await + .is_ok(), + "precondition: neither agent nor owner is banned" + ); + + // Ban the human only. The agent's own row stays clear. + db.ban_community_member(community, &owner_pk, &actor, Some("test"), None) + .await + .expect("ban owner"); + + let (status, _) = denial_parts( + deny_banned_git_principal(&db, community, &agent.public_key(), Some(&auth_tag)).await, + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "banning the owner must revoke its agent's git access" + ); + + // An unattested request from the same agent key is unaffected: the + // cascade must follow a verified owner, not punish every agent. + assert!( + deny_banned_git_principal(&db, community, &agent.public_key(), None) + .await + .is_ok(), + "without an attestation there is no owner to inherit from" + ); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ban_gate_fails_closed_with_503_when_the_store_is_unreachable() { + let url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_string()); + let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); + let db = buzz_db::Db::from_pool(pool.clone()); + + // Closing the pool is the cheapest faithful stand-in for the + // restriction store being unavailable mid-request. + pool.close().await; + + let community = buzz_core::CommunityId::from_uuid(uuid::Uuid::new_v4()); + let (status, body) = denial_parts( + deny_banned_git_principal(&db, community, &Keys::generate().public_key(), None).await, + ) + .await; + assert_eq!( + status, + StatusCode::SERVICE_UNAVAILABLE, + "a store outage must deny as retryable, never allow and never claim a 403" + ); + assert_eq!(body, "authorization unavailable"); + } } diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index 85a0ca2efe..dd50973d03 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -46,6 +46,10 @@ pub struct JoinPolicyConfig { pub version: String, } +/// Maximum configured jitter, leaving ten seconds of the hard-drain budget for +/// WebSocket close-frame delivery after the final delayed cancellation. +pub const MAX_DRAIN_JITTER_MS: u64 = 20_000; + /// Relay runtime configuration, loaded from environment variables. #[derive(Debug, Clone)] pub struct Config { @@ -60,6 +64,20 @@ pub struct Config { /// `0` (the default) disables bounded-staleness replica routing; see /// [`buzz_db::DbConfig::replica_read_max_age_ms`]. pub replica_read_max_age_ms: u64, + + /// Upper bound, in milliseconds, of the per-connection random delay applied + /// when sending the `1012 Service Restart` close frame during graceful + /// shutdown (`BUZZ_DRAIN_JITTER_MS`). Each live connection is closed after + /// an independent delay drawn uniformly from `[1, drain_jitter_ms]` when + /// jitter is enabled, which + /// spreads client reconnects across the window instead of releasing the + /// whole pod's sockets in one instant (the reconnect thundering herd that + /// drives DB pool-timeout bursts on rolling deploys). + /// + /// Default `0` reproduces the previous all-at-once close. Values above + /// [`MAX_DRAIN_JITTER_MS`] are capped, leaving headroom under the relay's + /// 30-second hard-drain timeout for close-frame delivery. + pub drain_jitter_ms: u64, /// Redis connection URL used by the pub/sub manager. pub redis_url: String, /// Maximum connections in the shared Redis pool. Defaults to 16. @@ -453,6 +471,25 @@ impl Config { Err(_) => 0, }; + // Drain jitter: 0 = off (default). Clamp oversized values so every + // delayed close is initiated with ten seconds left in the relay's + // hard-drain budget. An empty/whitespace-only value is treated as unset + // (jitter off), matching the sibling vars in this file — so setting the + // var to "" is a valid kill switch, not a crashloop. + let drain_jitter_ms = match std::env::var("BUZZ_DRAIN_JITTER_MS") { + Ok(raw) if raw.trim().is_empty() => 0, + Ok(raw) => raw + .trim() + .parse::() + .map_err(|_| { + ConfigError::InvalidValue( + "BUZZ_DRAIN_JITTER_MS must be a non-negative integer".to_string(), + ) + })? + .min(MAX_DRAIN_JITTER_MS), + Err(_) => 0, + }; + let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string()); @@ -934,6 +971,7 @@ impl Config { database_url, read_database_url, replica_read_max_age_ms, + drain_jitter_ms, redis_url, redis_pool_size, db_pool_size, @@ -1267,6 +1305,60 @@ mod tests { } } + #[test] + fn drain_jitter_defaults_off_and_rejects_junk() { + let _guard = ENV_MUTEX.lock().unwrap(); + let previous = std::env::var_os("BUZZ_DRAIN_JITTER_MS"); + + std::env::remove_var("BUZZ_DRAIN_JITTER_MS"); + let unset = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "20000"); + let set = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "60000"); + let capped = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "0"); + let zero = Config::from_env().expect("config").drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", "soon"); + let junk = Config::from_env(); + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", ""); + let empty = Config::from_env() + .expect("empty is a valid kill switch") + .drain_jitter_ms; + + std::env::set_var("BUZZ_DRAIN_JITTER_MS", " "); + let blank = Config::from_env() + .expect("whitespace-only is a valid kill switch") + .drain_jitter_ms; + + if let Some(value) = previous { + std::env::set_var("BUZZ_DRAIN_JITTER_MS", value); + } else { + std::env::remove_var("BUZZ_DRAIN_JITTER_MS"); + } + + assert_eq!(unset, 0, "drain jitter must default off"); + assert_eq!(set, MAX_DRAIN_JITTER_MS); + assert_eq!( + capped, MAX_DRAIN_JITTER_MS, + "oversized jitter leaves close-frame flush headroom" + ); + assert_eq!(zero, 0, "explicit 0 is off"); + assert!( + junk.is_err(), + "an unparsable jitter must fail loudly, not silently disable" + ); + assert_eq!( + empty, 0, + "an empty value is treated as unset — a kill switch, not a crashloop" + ); + assert_eq!(blank, 0, "a whitespace-only value is treated as unset"); + } + #[test] fn audit_logging_defaults_on_and_accepts_explicit_off() { let _guard = ENV_MUTEX.lock().unwrap(); diff --git a/crates/buzz-relay/src/connection.rs b/crates/buzz-relay/src/connection.rs index 96e266779f..72a7eb9126 100644 --- a/crates/buzz-relay/src/connection.rs +++ b/crates/buzz-relay/src/connection.rs @@ -29,6 +29,11 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(5); /// Shared mutable subscription map for a single WebSocket connection. pub(crate) type ConnectionSubscriptions = Arc>>>; +/// Request for the writer to flush a restart close and report the result. +pub(crate) struct RestartClose { + pub(crate) flushed: tokio::sync::oneshot::Sender, +} + /// Maximum outbound data frames buffered into the websocket sink before one flush. const MAX_WS_SEND_BATCH: usize = 64; @@ -161,6 +166,11 @@ async fn handle_active_connection( // even when the data buffer is full. let (ctrl_tx, ctrl_rx) = mpsc::channel::(8); + // Dedicated restart-close channel carries a flush acknowledgement. Keeping + // ordinary control frames unchanged avoids coupling heartbeat/ban traffic + // to graceful-shutdown delivery tracking. + let (restart_tx, restart_rx) = mpsc::channel::(1); + let backpressure_count = Arc::new(AtomicU8::new(0)); let subscriptions = Arc::new(Mutex::new(HashMap::new())); @@ -205,6 +215,7 @@ async fn handle_active_connection( conn_id, tx.clone(), ctrl_tx.clone(), + Some(restart_tx), cancel.clone(), conn.tenant.community(), Arc::clone(&backpressure_count), @@ -215,7 +226,7 @@ async fn handle_active_connection( let (ws_send, ws_recv) = socket.split(); let send_cancel = cancel.child_token(); - let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, send_cancel)); + let send_task = tokio::spawn(send_loop(ws_send, rx, ctrl_rx, restart_rx, send_cancel)); let missed_pongs = Arc::new(AtomicU8::new(0)); let heartbeat_cancel = cancel.clone(); @@ -297,15 +308,17 @@ async fn send_loop( ws_send: futures_util::stream::SplitSink, data_rx: mpsc::Receiver, ctrl_rx: mpsc::Receiver, + restart_rx: mpsc::Receiver, cancel: CancellationToken, ) { - send_loop_inner(ws_send, data_rx, ctrl_rx, cancel).await; + send_loop_inner(ws_send, data_rx, ctrl_rx, restart_rx, cancel).await; } async fn send_loop_inner( mut ws_send: S, mut data_rx: mpsc::Receiver, mut ctrl_rx: mpsc::Receiver, + mut restart_rx: mpsc::Receiver, cancel: CancellationToken, ) where S: Sink + Unpin, @@ -319,9 +332,21 @@ async fn send_loop_inner( } tokio::select! { - // Biased: cancel > control > data. Cancel must win immediately - // so backpressure-triggered shutdown isn't starved by queued data. + // Biased: restart > cancel > ordinary control > data. A restart + // command owns shutdown delivery and must flush its 1012 before + // cancellation can fall back to an unacknowledged close. biased; + Some(restart) = restart_rx.recv() => { + let sent = ws_send + .send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: axum::extract::ws::close_code::RESTART, + reason: axum::extract::ws::Utf8Bytes::from_static("relay restarting"), + }))) + .await + .is_ok(); + let _ = restart.flushed.send(sent); + break; + } _ = cancel.cancelled() => { // Drain any queued control frames before closing. A ban // disconnect queues its `OK false "blocked: …"` reason frame on @@ -797,7 +822,8 @@ mod tests { } let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -817,7 +843,8 @@ mod tests { .expect("queue data frame"); let (sink, state) = MockSink::new(Some(1)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 1); @@ -842,7 +869,8 @@ mod tests { .expect("queue control frame"); let (sink, state) = MockSink::new(Some(2)); - send_loop_inner(sink, data_rx, ctrl_rx, CancellationToken::new()).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!(state.flush_count, 2); @@ -852,6 +880,57 @@ mod tests { ); } + #[tokio::test] + async fn send_loop_acknowledges_restart_after_flushing_exactly_one_1012() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart close"); + + let (sink, state) = MockSink::new(None); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + + assert_eq!(flushed_rx.await, Ok(true)); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 1, "ack follows the close flush"); + assert_eq!(state.messages.len(), 1, "writer exits after restart close"); + match &state.messages[0] { + WsMessage::Close(Some(close)) => { + assert_eq!(close.code, axum::extract::ws::close_code::RESTART); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected one 1012 restart close, got {other:?}"), + } + } + + #[tokio::test] + async fn send_loop_reports_restart_flush_failure() { + let (_data_tx, data_rx) = mpsc::channel(1); + let (_ctrl_tx, ctrl_rx) = mpsc::channel(1); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + restart_tx + .send(RestartClose { + flushed: flushed_tx, + }) + .await + .expect("queue restart close"); + + let (sink, state) = MockSink::new(Some(1)); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, CancellationToken::new()).await; + + assert_eq!(flushed_rx.await, Ok(false)); + let state = state.lock().expect("mock sink poisoned"); + assert_eq!(state.flush_count, 1); + assert_eq!(state.messages.len(), 1, "no fallback close is appended"); + } + #[tokio::test] async fn send_loop_flushes_queued_control_before_close_on_cancel() { // A ban disconnect queues its `OK false "blocked: …"` reason frame on @@ -871,7 +950,8 @@ mod tests { cancel.cancel(); let (sink, state) = MockSink::new(None); - send_loop_inner(sink, data_rx, ctrl_rx, cancel).await; + let (_restart_tx, restart_rx) = mpsc::channel(1); + send_loop_inner(sink, data_rx, ctrl_rx, restart_rx, cancel).await; let state = state.lock().expect("mock sink poisoned"); assert_eq!( diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index a9cdffcdec..a67797385b 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -1459,6 +1459,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2098,6 +2099,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -2423,6 +2425,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, CancellationToken::new(), community_id, Arc::new(AtomicU8::new(0)), diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 00e8968c6b..a78fd57900 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -50,6 +50,55 @@ use crate::conformance::{ state_for_request, EmitGuard, TraceAction, Verdict, }; +fn validate_custom_emoji_tags(event: &Event) -> Result<(), IngestError> { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(String::as_str) != Some("emoji") { + continue; + } + let shortcode = parts.get(1).ok_or_else(|| { + IngestError::Rejected("invalid: emoji tag must include a shortcode".into()) + })?; + buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + } + Ok(()) +} + +fn validate_reaction_emoji(event: &Event, emoji: &str) -> Result<(), IngestError> { + let emoji_char_count = emoji.chars().count(); + if emoji_char_count <= 64 { + return Ok(()); + } + + let Some(shortcode) = emoji + .strip_prefix(':') + .and_then(|value| value.strip_suffix(':')) + else { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + }; + let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode) + .map_err(|err| IngestError::Rejected(format!("invalid: {err}")))?; + if shortcode != normalized { + return Err(IngestError::Rejected( + "invalid: long custom emoji reaction shortcode must be canonical lowercase".into(), + )); + } + let has_matching_tag = event.tags.iter().any(|tag| { + let parts = tag.as_slice(); + parts.first().map(String::as_str) == Some("emoji") + && parts.get(1).is_some_and(|value| value == shortcode) + }); + if !has_matching_tag || emoji_char_count > buzz_sdk::MAX_CUSTOM_EMOJI_REACTION_LEN { + return Err(IngestError::Rejected(format!( + "invalid: reaction emoji exceeds 64 characters (got {emoji_char_count})" + ))); + } + Ok(()) +} + /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { @@ -2647,6 +2696,10 @@ async fn ingest_event_inner( )); } + if kind_u32 == KIND_EMOJI_SET || kind_u32 == KIND_EMOJI_LIST { + validate_custom_emoji_tags(&event)?; + } + // Resolve the target reference, then use one DB transaction to upsert the // reaction row (dedup via ON CONFLICT) with reaction_event_id already set and // store the kind:7 event. This replaces the post-storage side-effect handler. @@ -2685,17 +2738,7 @@ async fn ingest_event_inner( &event.content }; - // Mirror the SDK's 64-character emoji limit server-side so raw clients - // cannot bypass it. Uses chars().count() (not byte len) to match the - // SDK's check_emoji_len, which also counts Unicode characters. - const MAX_REACTION_EMOJI_CHARS: usize = 64; - let emoji_char_count = emoji.chars().count(); - if emoji_char_count > MAX_REACTION_EMOJI_CHARS { - return Err(IngestError::Rejected(format!( - "invalid: reaction emoji exceeds {} characters (got {})", - MAX_REACTION_EMOJI_CHARS, emoji_char_count - ))); - } + validate_reaction_emoji(&event, emoji)?; // Atomically upsert the reaction row with this kind:7 event id, then store // the event in the same transaction. Ordering is load-bearing: active @@ -2929,6 +2972,84 @@ mod tests { }; use nostr::{EventBuilder, Kind}; + #[test] + fn reaction_validation_accepts_wrapped_max_shortcode() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(validate_reaction_emoji(&event, &event.content).is_ok()); + } + + #[test] + fn reaction_validation_rejects_mixed_case_max_shortcode() { + let shortcode = "Ab".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN / 2); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn reaction_validation_rejects_case_mismatched_tag() { + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let uppercase_shortcode = shortcode.to_uppercase(); + let event = EventBuilder::new(Kind::Custom(KIND_REACTION as u16), format!(":{shortcode}:")) + .tags([nostr::Tag::parse([ + "emoji", + &uppercase_shortcode, + "https://example.com/max.png", + ]) + .expect("emoji tag")]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign reaction"); + + assert!(matches!( + validate_reaction_emoji(&event, &event.content), + Err(IngestError::Rejected(_)) + )); + } + + #[test] + fn emoji_set_validation_enforces_shortcode_boundary() { + let max_shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let valid_event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &max_shortcode, "https://example.com/max.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign valid emoji set"); + assert!(validate_custom_emoji_tags(&valid_event).is_ok()); + + let shortcode = "a".repeat(buzz_sdk::MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + let event = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET as u16), "") + .tags([ + nostr::Tag::parse(["emoji", &shortcode, "https://example.com/long.png"]) + .expect("emoji tag"), + ]) + .sign_with_keys(&nostr::Keys::generate()) + .expect("sign emoji set"); + + assert!(matches!( + validate_custom_emoji_tags(&event), + Err(IngestError::Rejected(message)) if message.contains("exceeds 64 bytes") + )); + } + /// A banned relay admin must be refused with the same wire prefix and /// transport status as every other durable-restriction refusal: /// `blocked:` and (via `bridge.rs`'s `AuthFailed` arm) HTTP 403 — never @@ -3229,6 +3350,18 @@ mod tests { } } + #[test] + fn private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists() { + assert!( + required_scope_for_kind( + buzz_core::kind::KIND_PRIVATE_MANAGED_AGENT, + &make_dummy_event(), + ) + .is_err(), + "kind 30179 must not enter generic EVENT ingest before privacy and aggregate CAS deploy" + ); + } + #[test] fn ephemeral_kinds_not_in_scope_allowlist() { assert!(required_scope_for_kind(KIND_PRESENCE_UPDATE, &make_dummy_event()).is_err()); diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 660a55fef3..98f8a9aa84 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -355,28 +355,28 @@ pub async fn validate_admin_event( .iter() .find(|m| m.pubkey == actor_bytes) .and_then(|m| m.role.parse().ok()); - - // PUT_USER: open channels allow any authenticated user; private channels - // require the actor to be an existing member (any role can invite). - if channel.visibility == "private" { - if actor_role.is_none() { - return Err(anyhow::anyhow!("actor not authorized")); - } - - // Only owners/admins may grant elevated roles. - if requested_role.is_some_and(|r| r.is_elevated()) - && !actor_role.is_some_and(|r| r.is_elevated()) - { - return Err(anyhow::anyhow!( - "only owners/admins may grant elevated roles" - )); - } - } - - // Extract target pubkey from p tag let target_pubkey = extract_p_tag(event).ok_or_else(|| anyhow::anyhow!("missing p tag"))?; + // PUT_USER: open channels allow any authenticated user. Private + // channels only let owners/admins add another identity; otherwise + // any compromised member could extend access to channel history. + // + // A self-targeted add skips this check so an idempotent re-add + // still works. That is not a way into a private channel: ingest's + // `check_channel_membership` rejects a non-member (and a + // soft-removed member) before this validator runs, and `add_member` + // independently requires the self-inviter to hold an active role. + // Self-promotion is caught by the role-change guard below. + if channel.visibility == "private" + && target_pubkey != actor_bytes + && !actor_role.is_some_and(|r| r.is_elevated()) + { + return Err(anyhow::anyhow!( + "only owners/admins may add private-channel members" + )); + } + // Changing an ACTIVE existing member's role is privileged in both // directions, on every visibility. `get_members` filters // `removed_at IS NULL`, so a soft-removed row is deliberately not an diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 799cf9cf60..34dc2dfcf8 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -17,7 +17,7 @@ use buzz_db::{Db, DbConfig}; use buzz_pubsub::PubSubManager; use buzz_search::SearchService; -use buzz_relay::config::Config; +use buzz_relay::config::{Config, MAX_DRAIN_JITTER_MS}; use buzz_relay::metrics as relay_metrics; use buzz_relay::router::{build_health_router, build_router}; use buzz_relay::state::AppState; @@ -1189,6 +1189,37 @@ async fn run_periodic_until_cancelled( /// │ → graceful drain (30s) → exit │ /// └─────────────────────────────────────────────────────────┘ /// ``` +/// +/// ## Shutdown budget +/// +/// The full teardown, measured from SIGTERM, is bounded as follows: +/// +/// 1. `5s` grace. Readiness returns 503 immediately, then the process +/// sleeps 5 seconds so Kubernetes stops routing new traffic before any +/// listener closes. +/// 2. `GRACEFUL_DRAIN_TIMEOUT` (`30s`) hard drain. Started at the end of the +/// grace, this backstops the whole drain and force-exits the process if +/// exceeded. It bounds everything after the grace, not the grace itself. +/// +/// A single WebSocket can therefore stay open, from SIGTERM, for up to: +/// +/// ```text +/// 5s grace + up to 20s jitter + up to 5s close-frame ack = 30s +/// (fixed) (MAX_DRAIN_JITTER_MS) (RESTART_CLOSE_ACK_TIMEOUT) +/// ``` +/// +/// The 5s grace runs before the 30s hard-drain clock starts, so the jitter +/// (capped at [`buzz_relay::config::MAX_DRAIN_JITTER_MS`] = 20s) plus the +/// per-connection close-frame ack wait (`RESTART_CLOSE_ACK_TIMEOUT` = 5s in +/// `state.rs`) sum to 25s and stay inside the 30s hard drain. Total worst +/// case from SIGTERM to forced exit is 5s + 30s = 35s. Both fit inside the +/// chart's `terminationGracePeriodSeconds: 60` (`deploy/charts/buzz/values.yaml`), +/// which leaves headroom but assumes no `preStop` hook adds further delay. +/// With jitter off (`BUZZ_DRAIN_JITTER_MS=0`, the default) sockets close +/// all-at-once right after the grace, so the per-socket delay collapses to +/// roughly the 5s grace plus the ack wait. +const GRACEFUL_DRAIN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + async fn serve( router: axum::Router, health_router: axum::Router, @@ -1207,8 +1238,36 @@ async fn serve( let (shutdown_tx, _) = tokio::sync::watch::channel(false); let shutdown_flag = Arc::clone(&state.shutting_down); let drain_conn_manager = Arc::clone(&state.conn_manager); + let drain_jitter_ms = state.config.drain_jitter_ms; let tx = shutdown_tx.clone(); - tokio::spawn(async move { + // TODO(coverage): `serve`'s shutdown wiring has no automated test. The + // jittered drain helper (`ConnectionManager::drain_all_jittered`) is + // covered in `state.rs`, but coverage of the helper is not coverage of + // its use here: the three wiring facts below are currently unguarded, and + // mutating any one of them leaves the suite green. + // 1. Jitter dispatch: `drain_jitter_ms == 0` must pick `drain_all`, and + // a non-zero value must pick `drain_all_jittered(drain_jitter_ms)`. + // A mutant that inverts this condition ships jitter-off in prod. + // 2. The shutdown handle must be awaited before the abort. Dropping the + // `shutdown_handle.await` (both the UDS and TCP-only return paths) is + // the exact shape of the previously shipped detached-timer bug, + // relocated from the helper to the call site: the runtime can exit + // before delayed closes flush, so no client sees a 1012. + // 3. `shutdown_tx.send(true)` must reach every listener's + // `with_graceful_shutdown` future, on both the UDS and TCP-only paths. + // + // A focused test would refactor the drain/dispatch decision and the + // listener-shutdown fan-out into a small seam that does not need a bound + // socket or a real SIGTERM. One shape: extract the body of this spawned + // task into a `run_graceful_shutdown(state, shutdown_tx)` fn parameterised + // over a signal future and a clock, inject a fake `ConnectionManager` + // (or a trait over `drain_all` / `drain_all_jittered`) that records which + // path ran, drive it with `tokio::time` paused, and assert: (a) the right + // drain path ran for jitter 0 vs non-zero, (b) the drain future completed + // before the abort fired, and (c) each subscribed `watch` receiver + // observed `true`. This keeps the test off real ports and off wall-clock + // 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); info!("Shutdown signal received — readiness now returns 503"); @@ -1216,20 +1275,31 @@ async fn serve( tokio::time::sleep(std::time::Duration::from_secs(5)).await; info!("Starting graceful drain (30s timeout)"); let _ = tx.send(true); - // Tell every connected client to reconnect NOW. Without this, upgraded - // WebSocket connections outlive the listener drain: clients ride the - // dying pod until the forced exit below and only learn about the - // restart from a TCP reset. The 1012 close frame turns a 35s silent - // death into an immediate, well-attributed reconnect. - let closed = drain_conn_manager.drain_all(); + // Keep the original process-level backstop alive while listener and + // upgraded-socket shutdown proceeds. The caller aborts it only after + // Axum and the owned jitter drain have both completed. + let hard_shutdown = tokio::spawn(async { + tokio::time::sleep(GRACEFUL_DRAIN_TIMEOUT).await; + tracing::error!("Drain timeout exceeded — forcing exit"); + std::process::exit(1); + }); + let hard_shutdown_abort = hard_shutdown.abort_handle(); + // Stop accepting first, then close every live socket. Jitter off (the + // default) uses the original synchronous all-at-once drain; jitter on + // retains ownership of every delayed close until its 1012 frame has + // been flushed and acknowledged (or its send loop cancelled). + let closed = if drain_jitter_ms == 0 { + drain_conn_manager.drain_all() + } else { + drain_conn_manager.drain_all_jittered(drain_jitter_ms).await + }; info!( connections = closed, - "Sent restart close frame to all live WebSocket connections" + jitter_ms = drain_jitter_ms, + max_jitter_ms = MAX_DRAIN_JITTER_MS, + "Signalled restart close to all live WebSocket connections" ); - // Hard timeout: force exit if connections don't drain within 30s. - tokio::time::sleep(std::time::Duration::from_secs(30)).await; - tracing::error!("Drain timeout exceeded — forcing exit"); - std::process::exit(1); + hard_shutdown_abort }); let tcp_listener = tokio::net::TcpListener::bind(&config.bind_addr) @@ -1277,7 +1347,11 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("TCP server error: {e}"))?; + let hard_shutdown = shutdown_handle + .await + .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; uds_handle.abort(); + hard_shutdown.abort(); return Ok(()); } @@ -1298,6 +1372,10 @@ async fn serve( .await .map_err(|e| anyhow::anyhow!("Server error: {e}"))?; + let hard_shutdown = shutdown_handle + .await + .map_err(|e| anyhow::anyhow!("Shutdown task failed: {e}"))?; + hard_shutdown.abort(); Ok(()) } diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 41bcdd3c84..84c8911e47 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -9,6 +9,7 @@ use std::time::Instant; use axum::body::Bytes; use axum::extract::ws::{Message as WsMessage, Utf8Bytes as WsUtf8Bytes}; use dashmap::DashMap; +use futures_util::future::join_all; use tokio::sync::mpsc; use tokio::sync::Semaphore; use tokio::task::JoinHandle; @@ -31,10 +32,13 @@ use deadpool_redis; use crate::audio::AudioRoomManager; use crate::config::Config; -use crate::connection::ConnectionSubscriptions; +use crate::connection::{ConnectionSubscriptions, RestartClose}; use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); + +/// Leaves headroom under the process-wide drain deadline for a stalled writer. +const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); type ScopedRateLimiter = DashMap; @@ -45,6 +49,7 @@ struct ConnEntry { /// the send loop. Used to deliver a ban-disconnect frame that must reach /// the client before the socket is closed (see [`ConnectionManager::disconnect_pubkey`]). ctrl_tx: mpsc::Sender, + restart_tx: Option>, cancel: CancellationToken, /// Community resolved from the connection host at handshake. This is the /// receiver-side tenant label fan-out must compare against the event label. @@ -202,11 +207,12 @@ impl ConnectionManager { // Each argument is a distinct per-connection attribute stored verbatim in // `ConnEntry`; a params struct would only relocate the same fields. #[allow(clippy::too_many_arguments)] - pub fn register( + pub(crate) fn register( &self, conn_id: Uuid, tx: mpsc::Sender, ctrl_tx: mpsc::Sender, + restart_tx: Option>, cancel: CancellationToken, community_id: CommunityId, backpressure_count: Arc, @@ -220,6 +226,7 @@ impl ConnectionManager { ConnEntry { tx, ctrl_tx, + restart_tx, cancel, community_id, backpressure_count, @@ -231,7 +238,11 @@ impl ConnectionManager { // Insert-then-check pairs with drain_all's store-then-iterate: either // the drain iteration sees this entry, or this check sees the flag. // A registration that raced past the snapshot self-signals here, so - // no connection can outlive graceful shutdown unclosed. + // no connection can outlive graceful shutdown unclosed. A client that + // arrives mid-shutdown should be closed at once, so the self-signal + // always uses the immediate control-frame + cancel path regardless of + // whether jittered drain is enabled — jitter smears the sockets that + // were already established, not late arrivals. if self.draining.load(Ordering::SeqCst) { let _ = drain_ctrl_tx.try_send(Self::restart_close_frame()); drain_cancel.cancel(); @@ -335,6 +346,11 @@ impl ConnectionManager { /// Closes every live connection with a `1012 Service Restart` close frame. /// + /// This is the original, all-at-once drain, retained as the default path + /// (`BUZZ_DRAIN_JITTER_MS` unset or `0`). It is synchronous and returns as + /// soon as every close is queued and every connection cancelled, so the + /// caller's hard-drain timeout backstops delivery unchanged. + /// /// Called when graceful shutdown starts draining. Without this, upgraded /// WebSocket connections outlive the axum listener drain: clients ride the /// dying pod until the forced exit and then learn about the restart from a @@ -364,6 +380,82 @@ impl ConnectionManager { closed } + /// Closes every live connection with a `1012 Service Restart` frame, + /// spreading closes across `[1, jitter_ms]`. + /// + /// This is the jittered drain, used only when `BUZZ_DRAIN_JITTER_MS > 0`. + /// It is kept deliberately separate from [`Self::drain_all`] so that the + /// default (jitter-off) shutdown path is byte-for-byte the previously + /// shipped behavior; the new close-acknowledgement machinery only runs when + /// jitter is explicitly enabled. Once the jittered path is proven in + /// production for all cases, the two can be unified and the old one dropped. + /// + /// A pod under a rolling deploy can hold thousands of WebSocket sessions. + /// Closing them simultaneously ([`Self::drain_all`]) makes every client + /// reconnect at the same moment — a thundering herd that drives the DB + /// pool-timeout bursts observed on each roll. Delaying each connection's + /// close by an independent uniform random offset in `[1, jitter_ms]` + /// smears the reconnects across the window while keeping the well-attributed + /// 1012 close. + /// + /// Each delayed close is delivered over the connection's dedicated + /// [`RestartClose`] channel: the writer flushes the 1012 frame and + /// acknowledges the flush, so drain waits for confirmed delivery (up to + /// [`RESTART_CLOSE_ACK_TIMEOUT`]) rather than assuming it. If the channel is + /// full/closed or the ack times out, drain falls back to cancellation. + /// + /// The sticky drain flag is set before the first await, preserving + /// [`Self::drain_all`]'s shutdown-boundary race guarantee: a registration + /// that lands after the snapshot self-signals immediately (no jitter — a + /// client arriving mid-shutdown should be closed at once). The returned + /// future owns every delayed close, so the caller must await it before the + /// relay runtime is allowed to stop. + /// + /// Returns the number of connections signalled. + pub async fn drain_all_jittered(&self, jitter_ms: u64) -> usize { + // Store-then-snapshot pairs with register's insert-then-check: either + // the snapshot captures a registration, or it observes the sticky flag + // and self-signals immediately. + self.draining.store(true, Ordering::SeqCst); + let jitter_ms = jitter_ms.max(1); + let pending: Vec<_> = self + .connections + .iter() + .map(|entry| { + let ctrl_tx = entry.ctrl_tx.clone(); + let restart_tx = entry.restart_tx.clone(); + let cancel = entry.cancel.clone(); + let delay_ms = 1 + rand::random::() % jitter_ms; + async move { + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + let Some(restart_tx) = restart_tx else { + // Unit-only registrations do not own a writer task. + let _ = ctrl_tx.try_send(Self::restart_close_frame()); + cancel.cancel(); + return; + }; + let (flushed_tx, flushed_rx) = tokio::sync::oneshot::channel(); + if restart_tx + .try_send(RestartClose { + flushed: flushed_tx, + }) + .is_err() + { + cancel.cancel(); + return; + } + let flushed = tokio::time::timeout(RESTART_CLOSE_ACK_TIMEOUT, flushed_rx).await; + if !matches!(flushed, Ok(Ok(true))) { + cancel.cancel(); + } + } + }) + .collect(); + let count = pending.len(); + join_all(pending).await; + count + } + /// The WS close frame announcing a graceful restart: 1012 Service Restart. fn restart_close_frame() -> WsMessage { WsMessage::Close(Some(axum::extract::ws::CloseFrame { @@ -1245,6 +1337,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -1370,6 +1463,7 @@ mod tests { conn_id, tx, conn.ctrl_tx.clone(), + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::clone(&bp), @@ -1416,6 +1510,7 @@ mod tests { conn_a, tx_a, ctrl_tx_a, + None, CancellationToken::new(), community_a, Arc::new(AtomicU8::new(0)), @@ -1426,6 +1521,7 @@ mod tests { conn_b, tx_b, ctrl_tx_b, + None, CancellationToken::new(), community_b, Arc::new(AtomicU8::new(0)), @@ -1462,6 +1558,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel, buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), bp, @@ -1764,6 +1861,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), @@ -1792,6 +1890,117 @@ mod tests { ); } + #[tokio::test] + async fn drain_all_jittered_waits_for_writer_acknowledgement_without_cancelling() { + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, mut restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let drain_mgr = Arc::clone(&mgr); + let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); + let restart = restart_rx.recv().await.expect("restart command delivered"); + assert!(!drain.is_finished(), "drain waits for the writer flush"); + restart.flushed.send(true).expect("acknowledge flush"); + + assert_eq!(drain.await.expect("drain task"), 1); + assert!( + !cancel.is_cancelled(), + "successful flush does not use cancellation fallback" + ); + } + + #[tokio::test] + async fn drain_all_jittered_cancels_when_restart_channel_is_full_or_closed() { + for keep_receiver in [true, false] { + let mgr = ConnectionManager::new(); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, restart_rx) = mpsc::channel(1); + let (pending_tx, _pending_rx) = tokio::sync::oneshot::channel(); + if keep_receiver { + restart_tx + .try_send(RestartClose { + flushed: pending_tx, + }) + .expect("fill restart channel"); + } else { + drop(restart_rx); + } + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + assert_eq!(mgr.drain_all_jittered(1).await, 1); + assert!( + cancel.is_cancelled(), + "unavailable writer cancels as fallback" + ); + } + } + + #[tokio::test(start_paused = true)] + async fn drain_all_jittered_cancels_when_flush_ack_times_out() { + // A writer that accepts the restart command but never acknowledges the + // flush (e.g. wedged mid-send) must not stall the drain: after + // RESTART_CLOSE_ACK_TIMEOUT the connection falls back to cancellation. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, _ctrl_rx) = mpsc::channel(8); + let (restart_tx, mut restart_rx) = mpsc::channel(1); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + Some(restart_tx), + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let drain_mgr = Arc::clone(&mgr); + let drain = tokio::spawn(async move { drain_mgr.drain_all_jittered(1).await }); + // Take the restart command but hold the ack sender forever. + let restart = restart_rx.recv().await.expect("restart command delivered"); + assert!(!drain.is_finished(), "drain waits on the ack timeout"); + // Advance past the 5s ack timeout under paused time. + tokio::time::sleep(RESTART_CLOSE_ACK_TIMEOUT + std::time::Duration::from_millis(1)).await; + + assert_eq!(drain.await.expect("drain task"), 1); + assert!( + cancel.is_cancelled(), + "an un-acknowledged flush falls back to cancellation" + ); + drop(restart); + } + #[tokio::test] async fn drain_all_sends_restart_close_and_cancels_every_conn() { // Graceful shutdown must tell every live client to reconnect — across @@ -1808,6 +2017,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), community, Arc::new(AtomicU8::new(0)), @@ -1859,6 +2069,7 @@ mod tests { conn_id, tx, ctrl_tx.clone(), + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1906,6 +2117,7 @@ mod tests { conn_id, tx, ctrl_tx, + None, cancel.clone(), buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), Arc::new(AtomicU8::new(0)), @@ -1929,4 +2141,133 @@ mod tests { other => panic!("expected a restart close frame, got {other:?}"), } } + + #[tokio::test] + async fn drain_all_is_immediate() { + // The default (jitter-off) drain queues the frame and cancels + // synchronously — the frame is present the moment drain_all() returns. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let closed = mgr.drain_all(); + + assert_eq!(closed, 1); + assert!(cancel.is_cancelled(), "default drain cancels synchronously"); + assert!( + matches!( + ctrl_rx + .try_recv() + .expect("close frame delivered synchronously"), + WsMessage::Close(Some(_)) + ), + "the restart close is queued before drain_all() returns" + ); + } + + #[tokio::test(start_paused = true)] + async fn drain_all_jittered_defers_close_until_within_jitter_window() { + // With jitter, the close is deferred within the owned drain future. + // The sticky drain flag is still set immediately, so a late + // registration self-signals with no delay. + let mgr = Arc::new(ConnectionManager::new()); + let conn_id = Uuid::new_v4(); + let (tx, _rx) = mpsc::channel(8); + let (ctrl_tx, mut ctrl_rx) = mpsc::channel(8); + let cancel = CancellationToken::new(); + mgr.register( + conn_id, + tx, + ctrl_tx, + None, + cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + + let jitter_ms = 20_000u64; + // Poll the owned drain through its first await. Dropping this future + // would drop the timers too; the shutdown path must retain and await it. + let drain = mgr.drain_all_jittered(jitter_ms); + tokio::pin!(drain); + assert!( + futures_util::poll!(&mut drain).is_pending(), + "jittered drain remains pending while its timers are owned" + ); + + // Not closed yet — the delayed drain is parked on its timer. + assert!( + !cancel.is_cancelled(), + "jittered close is deferred, not synchronous" + ); + assert!( + ctrl_rx.try_recv().is_err(), + "no close frame queued before the delay elapses" + ); + + // A registration racing past the snapshot still self-signals at once, + // regardless of jitter — clients arriving mid-shutdown are closed now. + let late_id = Uuid::new_v4(); + let (late_tx, _late_rx) = mpsc::channel(8); + let (late_ctrl_tx, mut late_ctrl_rx) = mpsc::channel(8); + let late_cancel = CancellationToken::new(); + mgr.register( + late_id, + late_tx, + late_ctrl_tx, + None, + late_cancel.clone(), + buzz_core::tenant::CommunityId::from_uuid(Uuid::nil()), + Arc::new(AtomicU8::new(0)), + Arc::new(Mutex::new(HashMap::new())), + 3, + ); + assert!( + late_cancel.is_cancelled(), + "late registration self-signals immediately, unaffected by jitter" + ); + assert!( + matches!( + late_ctrl_rx.try_recv().expect("late close frame"), + WsMessage::Close(Some(_)) + ), + "late registration gets the restart close with no delay" + ); + + // Advance past the whole jitter window; awaiting the owned drain must + // complete only after the deferred close has fired. + tokio::time::advance(std::time::Duration::from_millis(jitter_ms + 1)).await; + assert_eq!(drain.await, 1, "one captured connection drained"); + + assert!( + cancel.is_cancelled(), + "the jittered connection is closed within the jitter window" + ); + match ctrl_rx.try_recv().expect("deferred close frame delivered") { + WsMessage::Close(Some(close)) => { + assert_eq!( + close.code, + axum::extract::ws::close_code::RESTART, + "jittered close is still 1012 Service Restart" + ); + assert_eq!(close.reason.as_str(), "relay restarting"); + } + other => panic!("expected a restart close frame, got {other:?}"), + } + } } diff --git a/crates/buzz-sdk/src/builders.rs b/crates/buzz-sdk/src/builders.rs index 9a139f0377..c3b4432101 100644 --- a/crates/buzz-sdk/src/builders.rs +++ b/crates/buzz-sdk/src/builders.rs @@ -120,6 +120,11 @@ fn check_repo_id(repo_id: &str) -> Result<(), SdkError> { Ok(()) } +/// Maximum length of a custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_SHORTCODE_LEN: usize = 64; +/// Maximum reaction payload length for a colon-wrapped custom emoji shortcode. +pub const MAX_CUSTOM_EMOJI_REACTION_LEN: usize = MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 2; + /// Validate and normalize a NIP-30 custom emoji shortcode. /// /// Shortcodes are case-insensitive in Buzz's relay-global set; lowercase @@ -131,9 +136,9 @@ pub fn normalize_custom_emoji_shortcode(shortcode: &str) -> Result 64 { + if trimmed.len() > MAX_CUSTOM_EMOJI_SHORTCODE_LEN { return Err(SdkError::InvalidInput(format!( - "emoji shortcode exceeds 64 bytes (got {})", + "emoji shortcode exceeds {MAX_CUSTOM_EMOJI_SHORTCODE_LEN} bytes (got {})", trimmed.len() ))); } @@ -2654,6 +2659,30 @@ mod tests { assert!(has_tag(&ev, "emoji", "party_parrot")); } + #[test] + fn custom_emoji_reaction_accepts_max_shortcode_length() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN); + let ev = sign( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/max.png").unwrap(), + ); + + assert_eq!(ev.content, format!(":{shortcode}:")); + assert_eq!(ev.content.chars().count(), MAX_CUSTOM_EMOJI_REACTION_LEN); + assert!(has_tag(&ev, "emoji", &shortcode)); + } + + #[test] + fn custom_emoji_reaction_rejects_overlong_shortcode() { + let eid = event_id(); + let shortcode = "a".repeat(MAX_CUSTOM_EMOJI_SHORTCODE_LEN + 1); + + assert!(matches!( + build_custom_emoji_reaction(eid, &shortcode, "https://example.com/too-long.png"), + Err(SdkError::InvalidInput(message)) if message.contains("exceeds 64 bytes") + )); + } + #[test] fn custom_emoji_set_happy_path() { let ev = sign( diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6f59299ed2..5b9a50b5b1 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -2201,6 +2201,10 @@ async fn create_private_channel_ws(client: &mut BuzzTestClient, keys: &Keys) -> } /// Submit a kind:9000 PUT_USER event over WebSocket. +/// +/// `allow_self_tagging` keeps self-targeted adds working: EventBuilder otherwise +/// drops a `p` tag matching the signer (nostr-0.44.3 builder.rs:435-449) and the +/// event fails as "missing p tag" instead of exercising the authority check. async fn add_member_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2210,6 +2214,7 @@ async fn add_member_ws( let h_tag = Tag::parse(["h", channel_id]).unwrap(); let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag]) .sign_with_keys(signer) .unwrap(); @@ -2219,6 +2224,8 @@ async fn add_member_ws( } /// Submit a kind:9000 PUT_USER event with a role tag over WebSocket. +/// +/// See [`add_member_ws`] for why `allow_self_tagging` is required. async fn add_member_with_role_ws( client: &mut BuzzTestClient, channel_id: &str, @@ -2230,6 +2237,7 @@ async fn add_member_with_role_ws( let p_tag = Tag::parse(["p", target_pubkey_hex]).unwrap(); let role_tag = Tag::parse(["role", role]).unwrap(); let event = EventBuilder::new(Kind::Custom(9000), "") + .allow_self_tagging() .tags([h_tag, p_tag, role_tag]) .sign_with_keys(signer) .unwrap(); @@ -2241,10 +2249,10 @@ async fn add_member_with_role_ws( (ok.accepted, ok.message) } -/// Any member of a private channel can invite another user (Slack model). +/// Only owners/admins can add another identity to a private channel. #[tokio::test] #[ignore] -async fn test_private_channel_any_member_can_invite() { +async fn test_private_channel_member_cannot_invite() { let url = relay_url(); let owner_keys = Keys::generate(); let member_keys = Keys::generate(); @@ -2271,7 +2279,7 @@ async fn test_private_channel_any_member_can_invite() { .await .expect("connect as member"); - // Regular member invites a third user — this should succeed. + // Regular member tries to invite a third user. let (accepted, msg) = add_member_ws( &mut member_client, &channel_id, @@ -2279,15 +2287,77 @@ async fn test_private_channel_any_member_can_invite() { &member_keys, ) .await; + assert!( + !accepted, + "regular member must not add another private-channel identity: {msg}" + ); + assert!( + msg.contains("owners/admins"), + "rejection should name the owner/admin requirement, got: {msg}" + ); + + // The same member re-adding *themselves* stays idempotent — the huddle + // bot-add and kind:9021 paths depend on a self-targeted PUT_USER working. + let (accepted, msg) = add_member_ws( + &mut member_client, + &channel_id, + &member_keys.public_key().to_hex(), + &member_keys, + ) + .await; assert!( accepted, - "regular member should be able to invite to private channel, got: {msg}" + "self-targeted re-add must stay idempotent, got: {msg}" ); owner_client.disconnect().await.expect("disconnect owner"); member_client.disconnect().await.expect("disconnect member"); } +/// An admin — not just the owner — can still add to a private channel. +#[tokio::test] +#[ignore] +async fn test_private_channel_admin_can_invite() { + let url = relay_url(); + let owner_keys = Keys::generate(); + let admin_keys = Keys::generate(); + let invitee_keys = Keys::generate(); + + let mut owner_client = BuzzTestClient::connect(&url, &owner_keys) + .await + .expect("connect as owner"); + let channel_id = create_private_channel_ws(&mut owner_client, &owner_keys).await; + + let (accepted, msg) = add_member_with_role_ws( + &mut owner_client, + &channel_id, + &admin_keys.public_key().to_hex(), + "admin", + &owner_keys, + ) + .await; + assert!(accepted, "owner should add an admin, got: {msg}"); + + let mut admin_client = BuzzTestClient::connect(&url, &admin_keys) + .await + .expect("connect as admin"); + + let (accepted, msg) = add_member_ws( + &mut admin_client, + &channel_id, + &invitee_keys.public_key().to_hex(), + &admin_keys, + ) + .await; + assert!( + accepted, + "admin should be able to add to a private channel, got: {msg}" + ); + + owner_client.disconnect().await.expect("disconnect owner"); + admin_client.disconnect().await.expect("disconnect admin"); +} + /// A non-member cannot invite someone to a private channel. #[tokio::test] #[ignore] diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index 7aaa3d1702..e142221169 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -956,18 +956,10 @@ pub fn build_trigger_context(event: &buzz_core::StoredEvent) -> executor::Trigge let kind_u32 = event_kind_u32(&event.event); let content = event.event.content.clone(); - let author = event - .event - .tags - .iter() - .find_map(|tag| { - if tag.kind().to_string() == "actor" { - tag.content().map(|value| value.to_string()) - } else { - None - } - }) - .unwrap_or_else(|| event.event.pubkey.to_hex()); + // Workflow conditions make authorization decisions from `trigger_author`, + // so it must come from the event signature. An `actor` tag is ordinary + // signer-controlled metadata and cannot speak for another pubkey. + let author = event.event.pubkey.to_hex(); // For reaction events (NIP-25), the content field holds the emoji character // or shortcode (e.g. "👍", "+", "-"). Expose it as `emoji`. @@ -1608,6 +1600,24 @@ steps: assert!(ctx.author.chars().all(|c| c.is_ascii_hexdigit())); } + #[test] + fn build_trigger_context_ignores_actor_tag() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + + let signer = Keys::generate(); + let impersonated = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "forged actor") + .tags([Tag::parse(["actor", &impersonated.public_key().to_hex()]).expect("actor tag")]) + .sign_with_keys(&signer) + .expect("sign"); + let stored = buzz_core::StoredEvent::new(event, Some(uuid::Uuid::new_v4())); + + let ctx = build_trigger_context(&stored); + + assert_eq!(ctx.author, signer.public_key().to_hex()); + assert_ne!(ctx.author, impersonated.public_key().to_hex()); + } + #[test] fn build_trigger_context_message_id_is_hex() { let stored = make_message_event(); diff --git a/deploy/charts/buzz/templates/deployment.yaml b/deploy/charts/buzz/templates/deployment.yaml index 67a93138c5..5c876f7d24 100644 --- a/deploy/charts/buzz/templates/deployment.yaml +++ b/deploy/charts/buzz/templates/deployment.yaml @@ -128,6 +128,7 @@ spec: - { name: BUZZ_MAX_CONNECTIONS, value: {{ .Values.relay.maxConnections | quote }} } - { name: BUZZ_MAX_CONCURRENT_HANDLERS, value: {{ .Values.relay.maxConcurrentHandlers | quote }} } - { name: BUZZ_SEND_BUFFER, value: {{ .Values.relay.sendBuffer | quote }} } + - { name: BUZZ_DRAIN_JITTER_MS, value: {{ .Values.relay.drainJitterMs | quote }} } - { name: BUZZ_REQUIRE_AUTH_TOKEN, value: {{ .Values.relay.requireAuthToken | quote }} } - { name: BUZZ_REQUIRE_RELAY_MEMBERSHIP, value: {{ .Values.relay.requireRelayMembership | quote }} } - { name: BUZZ_REQUIRE_MEDIA_GET_AUTH, value: {{ .Values.relay.requireMediaGetAuth | quote }} } diff --git a/deploy/charts/buzz/values.schema.json b/deploy/charts/buzz/values.schema.json index 9cb6a02c9b..e1e362a531 100644 --- a/deploy/charts/buzz/values.schema.json +++ b/deploy/charts/buzz/values.schema.json @@ -59,6 +59,7 @@ "maxConnections": { "type": "integer", "minimum": 1 }, "maxConcurrentHandlers": { "type": "integer", "minimum": 1 }, "sendBuffer": { "type": "integer", "minimum": 1 }, + "drainJitterMs": { "type": "integer", "minimum": 0 }, "requireAuthToken": { "type": "boolean" }, "requireRelayMembership": { "type": "boolean" }, "requireMediaGetAuth": { "type": "boolean" }, diff --git a/deploy/charts/buzz/values.yaml b/deploy/charts/buzz/values.yaml index 810f8a9658..42b09f1b3e 100644 --- a/deploy/charts/buzz/values.yaml +++ b/deploy/charts/buzz/values.yaml @@ -105,6 +105,16 @@ relay: maxConnections: 10000 maxConcurrentHandlers: 1024 sendBuffer: 1000 + # Graceful-shutdown reconnect jitter. On SIGTERM the relay closes every live + # WebSocket with a 1012 Service Restart frame; with a rolling deploy this can + # release a whole pod's sockets at once and stampede reconnects into the DB + # pool. A positive value (milliseconds) spreads each close over a per-socket + # random delay in [1, drainJitterMs], smoothing the reconnect herd. 0 (the + # default) closes all sockets at once, preserving the previous behavior. + # Values above 20000 are capped to 20000, leaving close-frame delivery + # headroom under the relay's 30s hard-drain timeout (itself inside the 60s + # terminationGracePeriodSeconds below). + drainJitterMs: 0 requireAuthToken: true requireRelayMembership: true # Authenticated media reads: relay GET/HEAD /media/* requires Blossom diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index f3c2936fe9..c1ea0e061b 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -102,6 +102,7 @@ export default defineConfig({ "**/reaction-order.spec.ts", "**/reaction-names.spec.ts", "**/inbox-reactions.spec.ts", + "**/inbox-edit.spec.ts", "**/send-channel-binding.spec.ts", "**/project-commit-detail.spec.ts", "**/project-inbox.spec.ts", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 5daaf4aa13..76720f8323 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1107,6 +1107,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "opus", "plist", "png 0.18.1", @@ -6773,6 +6774,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.0", + "block2", "objc2", "objc2-foundation", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1ba814da47..bbf245e29a 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -54,7 +54,8 @@ webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" diff --git a/desktop/src-tauri/build.rs b/desktop/src-tauri/build.rs index 2b997af891..2cdd785c73 100644 --- a/desktop/src-tauri/build.rs +++ b/desktop/src-tauri/build.rs @@ -1,6 +1,9 @@ // Shared schema, included from the same source the runtime command parses with, // so the build-time validation below and the runtime parse cannot drift. include!("src/commands/reconnect_hook_config.rs"); +// Same source of truth the runtime filters with, so a baked build env cannot +// carry a reserved key the runtime believes it already rejected. +include!("src/managed_agents/reserved_env_keys.rs"); use base64::Engine as _; @@ -13,9 +16,16 @@ fn main() { println!("cargo:rerun-if-env-changed=BUZZ_BUILD_BUZZ_AGENT_MODEL"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ENV"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_RELAY_RECONNECT_CMD"); + println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY"); println!("cargo:rerun-if-env-changed=BUZZ_BUILD_AUTO_CONNECT_DEFAULT_RELAY"); println!("cargo:rustc-check-cfg=cfg(buzz_updater_enabled)"); + // Explicit owner-only agent-access capability. Release packaging sets this + // presence-only marker; OSS/custom builds leave agent access configurable. + if std::env::var("BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY").is_ok() { + println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY=1"); + } + if let Ok(relay_url) = std::env::var("BUZZ_RELAY_URL") { println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_RELAY_URL={relay_url}"); } @@ -59,6 +69,20 @@ fn main() { line ); } + // The baked env is written into every spawned agent's environment + // LAST (see `managed_agents/runtime.rs`), after Buzz sets the + // access gates and identity vars. A baked reserved key would + // therefore silently override the gate the UI promises, so reject + // it at build time instead of shipping a binary that bypasses its + // own enforcement. + if is_reserved_env_key(key) { + panic!( + "BUZZ_BUILD_AGENT_ENV line {}: `{}` is reserved by Buzz and cannot be baked \ + into a build (it would override Buzz's own identity/access env)", + line_no + 1, + key + ); + } } let encoded = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); println!("cargo:rustc-env=BUZZ_DESKTOP_BUILD_AGENT_ENV={encoded}"); diff --git a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs index 6d99337c4b..59e94a1f63 100644 --- a/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs +++ b/desktop/src-tauri/crates/buzz-terminal/src/env_fence_tests.rs @@ -330,7 +330,10 @@ fn child_shell_is_the_resolved_shell_not_the_inherited_one() { /// grows a new secret, this points at the file to update. #[test] fn reserved_keys_are_covered() { - let source = include_str!("../../../src/managed_agents/env_vars.rs"); + // The list lives in its own file because `build.rs` `include!`s the same + // source (see `managed_agents/reserved_env_keys.rs`); read it there rather + // than through the module that includes it. + let source = include_str!("../../../src/managed_agents/reserved_env_keys.rs"); let declared: Vec<&str> = source .lines() .skip_while(|line| !line.contains("RESERVED_ENV_KEYS")) diff --git a/desktop/src-tauri/src/commands/agent_access.rs b/desktop/src-tauri/src/commands/agent_access.rs new file mode 100644 index 0000000000..ef118e82b2 --- /dev/null +++ b/desktop/src-tauri/src/commands/agent_access.rs @@ -0,0 +1,18 @@ +/// Return whether this build enforces owner-only managed-agent access. +#[tauri::command] +pub fn agent_access_owner_only() -> bool { + crate::managed_agents::owner_only_access_build() +} + +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"] + fn compiled_policy_matches_expected() { + let expected = std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set") + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"); + assert_eq!(super::agent_access_owner_only(), expected); + } +} diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 0eb024a86a..9609db5f2d 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -155,7 +155,6 @@ pub async fn save_custom_harness( Ok(AcpRuntimeCatalogEntry { id: definition.id, label: definition.label, - // Security: no user-supplied avatar URL in catalog entries. avatar_url: String::new(), availability, command: command_opt, @@ -177,8 +176,8 @@ pub async fn save_custom_harness( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env back so the edit form can read and preserve it. definition_env: definition.env, + max_parallelism: crate::managed_agents::harness_max_parallelism(&definition.command), }) } diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index 3b114b0474..dd61fc9398 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1355,12 +1355,12 @@ pub async fn delete_managed_agent( // 2. Harness sees it, exits gracefully, sets presence to "offline" // 3. Desktop's existing presence polling sees "offline" — UI updates automatically // No backend Tauri command needed. Presence IS the status. - #[path = "agents_deploy.rs"] mod deploy; +pub(super) mod provider_access; use deploy::build_deploy_payload; #[cfg(test)] -use deploy::deploy_payload_json; +use deploy::{deploy_payload_json, DeployProjections}; #[cfg(test)] use deploy::{ensure_remote_provider_supported, resolve_deploy_model_provider}; diff --git a/desktop/src-tauri/src/commands/agents/provider_access.rs b/desktop/src-tauri/src/commands/agents/provider_access.rs new file mode 100644 index 0000000000..467230e56f --- /dev/null +++ b/desktop/src-tauri/src/commands/agents/provider_access.rs @@ -0,0 +1,196 @@ +//! Upgrade reconciliation for provider-backed managed-agent access. + +use tauri::AppHandle; + +use crate::{ + app_state::AppState, + managed_agents::{ + find_managed_agent_mut, load_managed_agents, save_managed_agents, BackendKind, + ManagedAgentRecord, + }, + util::now_iso, +}; + +pub(super) fn needs_reconciliation_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> bool { + owner_only_access && record.backend != BackendKind::Local && record.backend_agent_id.is_some() +} + +#[derive(Debug)] +struct ProviderAccessTarget { + pubkey: String, + provider_id: String, + config: serde_json::Value, + cached_binary_path: Option, + agent_json: Result, +} + +fn collect_targets_with( + records: Vec, + owner_only_access: bool, + mut build_payload: impl FnMut(&ManagedAgentRecord) -> Result, +) -> Vec { + records + .into_iter() + .filter(|record| needs_reconciliation_with_policy(record, owner_only_access)) + .map(|record| match record.backend.clone() { + BackendKind::Provider { id, config } => ProviderAccessTarget { + agent_json: build_payload(&record), + pubkey: record.pubkey, + provider_id: id, + config, + cached_binary_path: record.provider_binary_path, + }, + BackendKind::Local => { + unreachable!("provider access reconciliation selected a local agent") + } + }) + .collect() +} + +/// Redeploy every existing provider agent in an owner-only access build. +/// +/// The saved `backend_agent_id` only proves that some provider deployment +/// exists. A marked build sends the current owner-only payload before each +/// community UI load. Workspace apply fails closed if any provider rejects it. +pub(crate) async fn reconcile_on_workspace_apply( + app: &AppHandle, + state: &AppState, +) -> Result<(), String> { + if !crate::managed_agents::owner_only_access_build() { + return Ok(()); + } + + let targets = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|error| error.to_string())?; + collect_targets_with(load_managed_agents(app)?, true, |record| { + super::build_deploy_payload(app, state, record) + }) + }; + + for target in targets { + let ProviderAccessTarget { + pubkey, + provider_id, + config, + cached_binary_path, + agent_json, + } = target; + let agent_json = match agent_json { + Ok(agent_json) => agent_json, + Err(error) => { + persist_failure(app, state, &pubkey, &error)?; + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + }; + if let Err(error) = super::deploy_to_provider( + app, + state, + &pubkey, + &provider_id, + &config, + agent_json, + cached_binary_path.as_deref(), + ) + .await + { + return Err(format!( + "provider access reconciliation failed for agent {pubkey}: {error}" + )); + } + } + + Ok(()) +} + +fn persist_failure( + app: &AppHandle, + state: &AppState, + pubkey: &str, + error: &str, +) -> Result<(), String> { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|lock_error| lock_error.to_string())?; + let mut records = load_managed_agents(app)?; + let record = find_managed_agent_mut(&mut records, pubkey)?; + record.last_error = Some(error.to_string()); + record.updated_at = now_iso(); + save_managed_agents(app, &records) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn record(backend: BackendKind, backend_agent_id: Option<&str>) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.backend_agent_id = backend_agent_id.map(str::to_string); + record + } + + #[test] + fn upgrade_collects_existing_provider_and_builds_projected_payload() { + let records = vec![ + record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({"region": "test"}), + }, + Some("existing"), + ), + record( + BackendKind::Provider { + id: "not-deployed".into(), + config: serde_json::json!({}), + }, + None, + ), + record(BackendKind::Local, Some("stale")), + ]; + + let targets = collect_targets_with(records, true, |_| { + Ok(serde_json::json!({"respond_to": "owner-only"})) + }); + + assert_eq!(targets.len(), 1); + assert_eq!(targets[0].pubkey, "agent"); + assert_eq!(targets[0].provider_id, "provider"); + assert_eq!(targets[0].config["region"], "test"); + assert_eq!( + targets[0].agent_json.as_ref().unwrap()["respond_to"], + "owner-only" + ); + } + + #[test] + fn unmarked_build_collects_no_upgrade_targets() { + let records = vec![record( + BackendKind::Provider { + id: "provider".into(), + config: serde_json::json!({}), + }, + Some("existing"), + )]; + + assert!( + collect_targets_with(records, false, |_| { Ok(serde_json::Value::Null) }).is_empty() + ); + } +} diff --git a/desktop/src-tauri/src/commands/agents_deploy.rs b/desktop/src-tauri/src/commands/agents_deploy.rs index b90bf49b3b..47ee5f92d4 100644 --- a/desktop/src-tauri/src/commands/agents_deploy.rs +++ b/desktop/src-tauri/src/commands/agents_deploy.rs @@ -14,6 +14,20 @@ use crate::{ relay::relay_ws_url_with_override, }; +/// Effective projection fields for the deploy payload — all derived from the +/// resolved descriptor and effective config so that the serialised payload and +/// the `launch` block are always internally consistent. +pub(super) struct DeployProjections { + pub effective_model: Option, + pub effective_provider: Option, + pub effective_prompt: Option, + /// Effective parallelism derived from the same resolved `descriptor.command` + /// as `launch.policy_env["BUZZ_ACP_AGENTS"]`. + pub effective_parallelism: u32, + /// Access fields projected from the same build policy that gates local starts. + pub owner_only_access: bool, +} + /// Resolve the deploy-specific structured model/provider for a managed agent. #[cfg(test)] pub(crate) fn resolve_deploy_model_provider( @@ -60,7 +74,10 @@ pub(super) fn build_launch_block( } policy_env.insert("BUZZ_ACP_RELAY_OBSERVER".into(), "true".into()); policy_env.insert("BUZZ_ACP_LAZY_POOL".into(), "true".into()); - policy_env.insert("BUZZ_ACP_AGENTS".into(), record.parallelism.to_string()); + policy_env.insert( + "BUZZ_ACP_AGENTS".into(), + crate::managed_agents::acp_agents_value(&descriptor.command, record.parallelism), + ); if let Some(value) = effective_prompt { policy_env.insert("BUZZ_ACP_SYSTEM_PROMPT".into(), value.to_string()); @@ -141,15 +158,22 @@ pub(super) fn build_deploy_payload( &owner_pubkey, ); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + Ok(deploy_payload_json( record, crate::relay::effective_agent_relay_url( &record.relay_url, &relay_ws_url_with_override(state), ), - effective.model.value, - effective.provider.value, - effective.system_prompt.value, + DeployProjections { + effective_model: effective.model.value, + effective_provider: effective.provider.value, + effective_prompt: effective.system_prompt.value, + effective_parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), + }, merged_user_env, launch, )) @@ -157,15 +181,18 @@ pub(super) fn build_deploy_payload( /// Pure serialization half of [`build_deploy_payload`]. Legacy top-level fields /// remain for display/bookkeeping; providers execute the resolved `launch` block. +/// `projections.effective_parallelism` is pre-computed from the same resolved +/// descriptor as `launch.policy_env["BUZZ_ACP_AGENTS"]`. Access is projected from +/// the same compiled policy that gates local starts. pub(super) fn deploy_payload_json( record: &ManagedAgentRecord, relay_url: String, - effective_model: Option, - effective_provider: Option, - effective_prompt: Option, + projections: DeployProjections, merged_env: BTreeMap, launch: serde_json::Value, ) -> serde_json::Value { + let (respond_to, respond_to_allowlist) = + crate::managed_agents::projected_access_with_policy(record, projections.owner_only_access); serde_json::json!({ "name": &record.name, "relay_url": relay_url, @@ -173,15 +200,17 @@ pub(super) fn deploy_payload_json( "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, - "system_prompt": effective_prompt, - "model": effective_model, - "provider": effective_provider, + "system_prompt": projections.effective_prompt, + "model": projections.effective_model, + "provider": projections.effective_provider, "turn_timeout_seconds": record.turn_timeout_seconds, "idle_timeout_seconds": record.idle_timeout_seconds, "max_turn_duration_seconds": record.max_turn_duration_seconds, - "parallelism": record.parallelism, - "respond_to": record.respond_to, - "respond_to_allowlist": &record.respond_to_allowlist, + // Legacy top-level field: projected from the same resolved descriptor as + // launch.policy_env["BUZZ_ACP_AGENTS"] — the two are always consistent. + "parallelism": projections.effective_parallelism, + "respond_to": respond_to, + "respond_to_allowlist": respond_to_allowlist, "env_vars": merged_env, "launch": launch, }) @@ -261,4 +290,189 @@ mod tests { assert_eq!(launch["policy_env"]["BUZZ_ACP_AGENTS"], "4"); assert_eq!(launch["owner_pubkey"], "owner-hex"); } + + /// OpenClaw descriptor: `launch.policy_env["BUZZ_ACP_AGENTS"]` must be "5" + /// even when the record's requested parallelism is 10. This is the direct + /// `launch.policy_env` seam test — the executable contract for remote providers. + #[test] + fn launch_block_openclaw_over_cap_policy_env_is_capped() { + let mut record = record(); + record.agent_command = "openclaw".into(); + record.parallelism = 10; // above the OpenClaw spawn-time cap + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {} for OpenClaw, not 10", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped harness (goose): `launch.policy_env["BUZZ_ACP_AGENTS"]` passes + /// the requested value through unchanged. + #[test] + fn launch_block_goose_policy_env_is_not_capped() { + let mut record = record(); + record.parallelism = 8; + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], "8", + "goose: policy_env[BUZZ_ACP_AGENTS] must pass through requested value 8" + ); + } + + /// deploy_payload_json: legacy top-level `parallelism` is the effective value + /// derived from the descriptor, not `record.agent_command`. + /// + /// Stale-persona scenario: `record.agent_command` is "goose" (created before + /// the user switched the persona to OpenClaw), but the live descriptor resolves + /// OpenClaw. Both `launch.policy_env["BUZZ_ACP_AGENTS"]` and the legacy + /// top-level `parallelism` must be the effective OpenClaw value (5), not the + /// record's stale Goose identity (requested 10). + #[test] + fn deploy_payload_json_stale_goose_record_live_openclaw_descriptor_both_capped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to OpenClaw. + record.agent_command = "goose".into(); + record.parallelism = 10; + // Resolved descriptor reflects the live persona (OpenClaw). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be capped at {cap} for live OpenClaw descriptor" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } + + /// Inverse stale-persona scenario: `record.agent_command` is "openclaw" + /// (created before the user switched the persona to Goose), but the live + /// descriptor resolves Goose. Both projections must be the uncapped requested + /// value (4), not the old OpenClaw cap. + #[test] + fn deploy_payload_json_stale_openclaw_record_live_goose_descriptor_both_uncapped() { + let mut record = record(); + // Stale agent_command from record creation — persona has since switched to Goose. + record.agent_command = "openclaw".into(); + record.parallelism = 4; + // Resolved descriptor reflects the live persona (Goose). + let descriptor = EffectiveHarnessDescriptor { + command: "goose".into(), + args: vec![], + env: BTreeMap::new(), + }; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + "4", + "launch.policy_env[BUZZ_ACP_AGENTS] must pass through requested 4 for live Goose descriptor" + ); + assert_eq!( + payload["parallelism"], 4, + "legacy top-level parallelism must match launch.policy_env — both must be 4 (uncapped)" + ); + } + + /// Explicit agent_command_override direction: record has an explicit override + /// pinning OpenClaw while the persona default is Goose. The override wins + /// via the descriptor — both projections must be capped at the OpenClaw limit. + #[test] + fn deploy_payload_json_explicit_openclaw_override_both_capped() { + let mut record = record(); + // Explicit override: user pinned OpenClaw on this agent. + record.agent_command_override = Some("openclaw".into()); + record.agent_command = "goose".into(); // persona default, overridden + record.parallelism = 10; + // Descriptor reflects the resolved override (OpenClaw wins). + let descriptor = EffectiveHarnessDescriptor { + command: "openclaw".into(), + args: vec![], + env: BTreeMap::new(), + }; + let cap = crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM; + + let launch = build_launch_block(&record, &descriptor, &[], None, None, "owner-hex"); + let effective_parallelism = + crate::managed_agents::effective_parallelism(&descriptor.command, record.parallelism); + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism, + owner_only_access: false, + }, + BTreeMap::new(), + launch.clone(), + ); + + assert_eq!( + launch["policy_env"]["BUZZ_ACP_AGENTS"], + cap.to_string(), + "launch.policy_env[BUZZ_ACP_AGENTS] must be {cap} for explicit OpenClaw override" + ); + assert_eq!( + payload["parallelism"], cap, + "legacy top-level parallelism must match launch.policy_env — both must be {cap}" + ); + } } diff --git a/desktop/src-tauri/src/commands/agents_tests.rs b/desktop/src-tauri/src/commands/agents_tests.rs index df135298c4..54a03e2bab 100644 --- a/desktop/src-tauri/src/commands/agents_tests.rs +++ b/desktop/src-tauri/src/commands/agents_tests.rs @@ -411,6 +411,27 @@ fn legacy_avatar_empty_when_nothing_resolves() { // ── Provider deploy payload completeness ───────────────────────────────────── +fn deploy_payload_for_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> serde_json::Value { + deploy_payload_json( + record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: Some("gpt-x".to_string()), + effective_provider: Some("openai".to_string()), + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access, + }, + std::collections::BTreeMap::new(), + // Access projection is the subject here; the launch block is exercised + // by the shared provider fixture test below. + serde_json::Value::Null, + ) +} + /// The shared provider fixture is the contract arbiter: it must be the exact /// richest deploy request produced by the real desktop serializers. #[test] @@ -465,9 +486,17 @@ fn deploy_payload_matches_the_shared_full_launch_fixture() { let agent = deploy_payload_json( &record, "wss://relay.example".into(), - Some("gpt-5".into()), - Some("openai".into()), - None, + DeployProjections { + effective_model: Some("gpt-5".into()), + effective_provider: Some("openai".into()), + effective_prompt: None, + effective_parallelism: crate::managed_agents::effective_parallelism( + &descriptor.command, + record.parallelism, + ), + // Fixture asserts the record's own access fields survive. + owner_only_access: false, + }, std::collections::BTreeMap::from([("USER_KEY".into(), "user-value".into())]), launch, ); @@ -501,3 +530,129 @@ fn tauri_platform_configs_bundle_kubernetes_only_on_supported_hosts() { ); } } + +#[test] +fn current_build_deploy_payload_forwards_compiled_policy() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let expected_owner_only = match std::env::var("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY") { + Ok(value) => value + .parse::() + .expect("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be true or false"), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!( + "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be set for owner-only-access-build tests" + ) + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY must be valid UTF-8") + } + }; + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_json( + &record, + "wss://relay.example".to_string(), + DeployProjections { + effective_model: None, + effective_provider: None, + effective_prompt: None, + effective_parallelism: record.parallelism, + owner_only_access: crate::managed_agents::owner_only_access_build(), + }, + std::collections::BTreeMap::new(), + // The compiled access policy is the subject here; the launch block is + // exercised by the shared provider fixture test above. + serde_json::Value::Null, + ); + let expected_mode = if expected_owner_only { + "owner-only" + } else { + "anyone" + }; + + assert_eq!( + payload["respond_to"], expected_mode, + "current-build deploy payload did not forward the compiled policy", + ); + let expected_allowlist = if expected_owner_only { + serde_json::json!([]) + } else { + serde_json::json!(["a".repeat(64)]) + }; + assert_eq!( + payload["respond_to_allowlist"], expected_allowlist, + "current-build deploy payload did not apply the compiled policy to the stale allowlist", + ); +} + +#[test] +fn provider_upgrade_reconciliation_targets_existing_deployments_only_in_marked_builds() { + use crate::managed_agents::BackendKind; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.backend_agent_id = Some("existing-provider-agent".to_string()); + record.respond_to = crate::managed_agents::RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + assert!(provider_access::needs_reconciliation_with_policy( + &record, true + )); + let payload = deploy_payload_for_policy(&record, true); + assert_eq!(payload["respond_to"], "owner-only"); + assert_eq!(payload["respond_to_allowlist"], serde_json::json!([])); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, false + )); + + record.backend_agent_id = None; + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); + + record.backend = BackendKind::Local; + record.backend_agent_id = Some("stale-provider-id".to_string()); + assert!(!provider_access::needs_reconciliation_with_policy( + &record, true + )); +} + +#[test] +fn owner_only_access_deploy_payload_clamps_stale_access() { + use crate::managed_agents::{BackendKind, RespondTo}; + + let mut record = bare_agent_record(None, None, None); + record.backend = BackendKind::Provider { + id: "provider".to_string(), + config: serde_json::json!({}), + }; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + + let payload = deploy_payload_for_policy(&record, true); + + assert_eq!( + payload["respond_to"], "owner-only", + "owner-only-access deploy payload widened stale access" + ); + assert_eq!( + payload["respond_to_allowlist"], + serde_json::json!([]), + "owner-only-access deploy payload retained a stale allowlist" + ); +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index 237bc06e8d..322834630a 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +mod agent_access; mod agent_auth; mod agent_config; mod agent_discovery; @@ -63,6 +64,7 @@ mod window_vibrancy; mod workflows; mod workspace; +pub use agent_access::*; pub use agent_auth::*; pub use agent_config::*; pub use agent_discovery::*; diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index c13d96ff6d..79aa15f969 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -1,4 +1,4 @@ -//! Native (Linux) desktop-notification helper. +//! Native desktop-notification helpers. //! //! `tauri-plugin-notification` posts a notification by calling `notify_rust`'s //! `show()` and then immediately dropping the returned `NotificationHandle`. @@ -13,13 +13,15 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; + /// Show a desktop notification natively. /// -/// On Linux this uses the connection-preserving path described above. On other -/// platforms the bundled notification plugin already works correctly, so the -/// frontend never calls this and we simply report that it is unused. +/// Linux uses the connection-preserving D-Bus path described above. macOS uses +/// one application-lifetime `UNUserNotificationCenterDelegate`; it does not +/// allocate a listener or waiter for each notification. #[tauri::command] -pub fn show_native_notification( +pub async fn show_native_notification( app: tauri::AppHandle, title: String, body: Option, @@ -31,21 +33,24 @@ pub fn show_native_notification( Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let _ = app; + crate::macos_notifications::show(title, body, target).await + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux".to_string()) + Err("show_native_notification is only supported on Linux and macOS".to_string()) } } #[cfg(target_os = "linux")] mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; use tauri::Emitter; - /// Emitted to the frontend when the user clicks a native notification. The - /// payload is the opaque target object the frontend passed in. - const ACTIVATE_EVENT: &str = "native-notification-activated"; - pub fn show( app: tauri::AppHandle, title: String, @@ -96,7 +101,7 @@ mod linux { // The frontend focuses the window on activation (the same path // every other platform uses), so we only forward the target. - let _ = app.emit(ACTIVATE_EVENT, target); + let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); }); }); } diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs index a9230dff95..9ccf8baac0 100644 --- a/desktop/src-tauri/src/commands/relay_members.rs +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -17,8 +17,15 @@ struct RelayInformationDocument { } #[tauri::command] -pub async fn relay_requires_membership(state: State<'_, AppState>) -> Result { - let url = format!("{}/info", relay_api_base_url_with_override(&state)); +pub async fn relay_requires_membership( + relay_url: Option, + state: State<'_, AppState>, +) -> Result { + let base_url = relay_url + .as_deref() + .map(crate::relay::relay_http_base_url) + .unwrap_or_else(|| relay_api_base_url_with_override(&state)); + let url = format!("{}/info", base_url.trim_end_matches('/')); let response = state .http_client .get(url) diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 731a99d9d9..aa88bfe39a 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -212,6 +212,8 @@ pub async fn apply_workspace( .map_err(|e| format!("spawn_blocking failed: {e}"))??; let state = restore_app.state::(); + super::agents::provider_access::reconcile_on_workspace_apply(&restore_app, &state).await?; + // Backfill this exact relay+owner scope only after the workspace has been // applied. Running at process boot would target the fallback relay and // collapse every community into one pending-event store. diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 93505648b1..7cb75c8112 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod identity_storage; mod initial_window; mod key_backup; mod linux_media; +#[cfg(target_os = "macos")] +mod macos_notifications; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -309,7 +311,10 @@ pub fn run() { .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] - tray_menu::init(&app_handle)?; + { + tray_menu::init(&app_handle)?; + macos_notifications::init(&app_handle)?; + } // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe @@ -720,6 +725,12 @@ pub fn run() { remove_reaction, get_event, show_native_notification, + #[cfg(target_os = "macos")] + macos_notifications::take_pending_activations, + #[cfg(target_os = "macos")] + macos_notifications::notification_permission_state, + #[cfg(target_os = "macos")] + macos_notifications::request_notification_access, upload_media, pick_and_upload_media, pick_and_upload_image, @@ -763,6 +774,7 @@ pub fn run() { get_managed_agent_log, get_agent_models, discover_agent_models, + agent_access_owner_only, get_agent_config_surface, get_runtime_file_config, get_baked_build_env_keys, diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs new file mode 100644 index 0000000000..da2312b457 --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -0,0 +1,376 @@ +//! Modern macOS notification delivery and activation routing. +//! +//! Apple delivers every notification response through one process-wide +//! `UNUserNotificationCenterDelegate`. The delegate is installed once during +//! app setup and retained for the process lifetime. Notification targets live +//! in `userInfo`, so there are no per-notification listeners, waiter threads, +//! or request maps to leak when Notification Center clears a notification. + +use std::{ + collections::VecDeque, + ptr::NonNull, + sync::{mpsc, Mutex, OnceLock}, + time::Duration, +}; + +use block2::{Block, RcBlock}; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{AnyObject, Bool, ProtocolObject}, + AnyThread, DefinedClass, +}; +use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::{AppHandle, Emitter}; + +use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + +const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; +const MAX_PENDING_ACTIVATIONS: usize = 64; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NotificationPermissionState { + Default, + Denied, + Granted, +} + +fn permission_state(status: UNAuthorizationStatus) -> NotificationPermissionState { + match status { + UNAuthorizationStatus::Denied => NotificationPermissionState::Denied, + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral => NotificationPermissionState::Granted, + _ => NotificationPermissionState::Default, + } +} + +static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); + +struct NotificationDelegateIvars { + app: AppHandle, +} + +define_class!( + // SAFETY: NSObject permits AnyThread subclasses, and AppHandle is Send + + // Sync. Apple does not guarantee a queue for notification delegate calls; + // both Tauri operations used by the callbacks are thread-safe. + #[unsafe(super(NSObject))] + #[name = "BuzzNotificationCenterDelegate"] + #[thread_kind = AnyThread] + #[ivars = NotificationDelegateIvars] + struct NotificationDelegate; + + unsafe impl NSObjectProtocol for NotificationDelegate {} + + unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate { + #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))] + fn will_present_notification( + &self, + _center: &UNUserNotificationCenter, + _notification: &objc2_user_notifications::UNNotification, + completion_handler: &Block, + ) { + // Preserve the prior macOS behavior: keep foreground notifications + // in Notification Center without interrupting the user with a banner. + completion_handler.call((UNNotificationPresentationOptions::List,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive_notification_response( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &Block, + ) { + if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { + if let Some(target) = target_from_response(response) { + queue_activation(target); + crate::tray_menu::show_main_window(&self.ivars().app); + if let Err(error) = self + .ivars() + .app + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ()) + { + eprintln!( + "buzz-desktop: failed to emit macOS notification activation: {error}" + ); + } + } + } + + // Apple requires this for every response, including dismissals and + // malformed notifications that Buzz intentionally ignores. + completion_handler.call(()); + } + } +); + +impl NotificationDelegate { + fn new(app: AppHandle) -> Retained { + let delegate = Self::alloc().set_ivars(NotificationDelegateIvars { app }); + unsafe { msg_send![super(delegate), init] } + } +} + +/// Install the one application-lifetime notification response delegate. +pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { + if !is_bundled_application() { + // UNUserNotificationCenter raises an Objective-C exception when the + // current process has no application bundle (notably `tauri dev`). + // objc2 cannot turn that exception into a Rust error, so do not call + // into the framework at all in this environment. + eprintln!( + "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + ); + return Ok(()); + } + + let center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationDelegate::new(app.clone()); + let delegate: Retained> = + ProtocolObject::from_retained(delegate); + center.setDelegate(Some(&delegate)); + + // UNUserNotificationCenter.delegate is weak. This object is deliberately + // process-lifetime state, matching the application-lifetime delegate Apple + // documents and avoiding mutable global or per-notification registrations. + std::mem::forget(delegate); + Ok(()) +} + +fn ensure_bundled_application() -> Result<(), String> { + if is_bundled_application() { + Ok(()) + } else { + Err( + "macOS notifications are unavailable when Buzz is not running from an app bundle" + .to_string(), + ) + } +} + +fn notification_permission_state_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |settings: NonNull| { + // SAFETY: Apple guarantees a live UNNotificationSettings object for + // the duration of this completion handler. + let status = unsafe { settings.as_ref() }.authorizationStatus(); + let _ = sender.send(permission_state(status)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification settings request timed out".to_string()) +} + +#[tauri::command] +pub(crate) async fn notification_permission_state() -> Result { + tokio::task::spawn_blocking(notification_permission_state_sync) + .await + .map_err(|error| format!("macOS notification settings task failed: {error}"))? +} + +fn request_notification_access_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("macOS notification authorization failed: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &handler, + ); + + receiver + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "macOS notification authorization request timed out".to_string())??; + notification_permission_state_sync() +} + +#[tauri::command] +pub(crate) async fn request_notification_access() -> Result { + tokio::task::spawn_blocking(request_notification_access_sync) + .await + .map_err(|error| format!("macOS notification authorization task failed: {error}"))? +} + +fn show_sync( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + ensure_bundled_application()?; + if notification_permission_state_sync()? != NotificationPermissionState::Granted { + return Err("macOS notification permission is not granted".to_string()); + } + + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(&title)); + if let Some(body) = body { + content.setBody(&NSString::from_str(&body)); + } + + if let Some(target) = target { + let serialized = serde_json::to_string(&target) + .map_err(|error| format!("failed to serialize notification target: {error}"))?; + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let value = NSString::from_str(&serialized); + let user_info = NSDictionary::::from_slices(&[&*key], &[&*value]); + // SAFETY: Both the key and value are property-list-safe NSString values. + unsafe { + let user_info = + Retained::cast_unchecked::>(user_info); + content.setUserInfo(&user_info); + } + } + + let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + let (sender, receiver) = mpsc::sync_channel(1); + let delivery_handler = RcBlock::new(move |error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("failed to deliver macOS notification: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification delivery request timed out".to_string())? +} + +pub(crate) async fn show( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || show_sync(title, body, target)) + .await + .map_err(|error| format!("macOS notification delivery task failed: {error}"))? +} + +fn queue_activation(target: serde_json::Value) { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: macOS notification activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); +} + +#[tauri::command] +pub(crate) fn take_pending_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "macOS notification activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) +} + +fn is_bundled_application() -> bool { + NSBundle::mainBundle().bundleIdentifier().is_some() +} + +fn target_from_response(response: &UNNotificationResponse) -> Option { + let user_info = response.notification().request().content().userInfo(); + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let target = user_info.objectForKey(key.as_ref())?; + let target = target.downcast::().ok()?; + parse_target(&target.to_string()) +} + +fn parse_target(serialized: &str) -> Option { + serde_json::from_str(serialized).ok() +} + +#[cfg(test)] +mod tests { + use super::{ + is_bundled_application, parse_target, permission_state, queue_activation, + take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, + }; + use objc2_user_notifications::UNAuthorizationStatus; + + #[test] + fn activation_queue_is_bounded_and_drained() { + let _ = take_pending_activations(); + for index in 0..=MAX_PENDING_ACTIVATIONS { + queue_activation(serde_json::json!({ "index": index })); + } + + let activations = take_pending_activations().expect("activation queue"); + assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS); + assert_eq!(activations[0]["index"], 1); + assert!(take_pending_activations() + .expect("drained activation queue") + .is_empty()); + } + + #[test] + fn cargo_test_process_is_not_treated_as_bundled() { + assert!(!is_bundled_application()); + } + + #[test] + fn maps_native_authorization_states_to_frontend_contract() { + assert_eq!( + permission_state(UNAuthorizationStatus::NotDetermined), + NotificationPermissionState::Default + ); + assert_eq!( + permission_state(UNAuthorizationStatus::Denied), + NotificationPermissionState::Denied + ); + for status in [ + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + assert_eq!( + permission_state(status), + NotificationPermissionState::Granted + ); + } + } + + #[test] + fn parses_opaque_notification_target() { + let target = + parse_target(r#"{"channelId":"channel","eventId":"event","threadRootId":"root"}"#) + .expect("valid target"); + + assert_eq!(target["channelId"], "channel"); + assert_eq!(target["eventId"], "event"); + assert_eq!(target["threadRootId"], "root"); + } + + #[test] + fn rejects_malformed_notification_target() { + assert!(parse_target("not-json").is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/access_policy.rs b/desktop/src-tauri/src/managed_agents/access_policy.rs new file mode 100644 index 0000000000..2d8326abc3 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/access_policy.rs @@ -0,0 +1,190 @@ +//! Distribution policy at managed-agent enforcement boundaries. +//! +//! ## What this build capability guarantees, and what it does not +//! +//! `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` marks a build whose managed agents may +//! answer only their owner. Enforcement is applied at the two boundaries where +//! Desktop hands access to something that runs the agent, and nowhere else. The +//! stored record and its relay-advertised access fields are left untouched, so +//! the same profile keeps its user-chosen access when it is opened in an OSS +//! build. +//! +//! Enforced: +//! +//! - **Local spawn.** [`build_respond_to_env_with_policy`] clamps +//! `BUZZ_ACP_RESPOND_TO` to `owner-only` and pins the independent +//! `BUZZ_ACP_ALLOWED_RESPOND_TO=owner-only` guard on every start, whatever +//! the record says. +//! - **Provider deployment, including upgrades.** +//! [`projected_access_with_policy`] projects owner-only into every payload. +//! Workspace apply redeploys each existing provider agent before the marked +//! build renders community UI. A failed redeploy fails the apply, so Desktop +//! does not present the locked owner-only control as applied while the remote +//! deployment may still use a wider policy. +//! +//! ## "owner-only" is owner plus verified same-owner sibling agents +//! +//! The harness gate this projection targets admits the human owner *and* every +//! cryptographically NIP-OA-verified agent that shares that owner (see +//! `crates/buzz-acp/src/lib.rs`). That is the intended boundary, not an +//! oversight: an owner's own agents are inside their trust boundary, and Buzz's +//! built-in Welcome team relies on it, because the lead instructs its teammates +//! while every teammate is created owner-only (see +//! `welcomeTeammateHasExpectedAccess` in +//! `desktop/src/features/onboarding/welcomeGuide.ts`). Read every use of +//! "owner-only" in this module as `owner ∪ verified same-owner agents`. The +//! setting's own copy says so: the line under Only me reads "Only you and your +//! agents can send instructions." (`RespondToField.tsx`). The dropdown label +//! stays "Only me", which is the audience the user picks. + +use super::{validate_respond_to_allowlist, ManagedAgentRecord, RespondTo}; + +pub(crate) type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); + +/// Release packaging sets `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY`; OSS/custom +/// builds do not. +pub(crate) fn owner_only_access_build() -> bool { + option_env!("BUZZ_DESKTOP_BUILD_AGENT_ACCESS_OWNER_ONLY").is_some() +} + +pub(crate) fn owner_only() -> bool { + owner_only_with_policy(owner_only_access_build()) +} + +pub(crate) fn owner_only_with_policy(owner_only_access: bool) -> bool { + owner_only_access +} + +/// Project effective access at a behavioral boundary without changing the +/// stored or relay-advertised access fields. +pub(crate) fn projected_access_with_policy( + record: &ManagedAgentRecord, + owner_only_access: bool, +) -> (RespondTo, Vec) { + if owner_only_with_policy(owner_only_access) { + (RespondTo::OwnerOnly, Vec::new()) + } else { + (record.respond_to, record.respond_to_allowlist.clone()) + } +} + +/// Build the inbound-author access environment for a launched agent. The +/// explicit policy input keeps owner-only access enforcement testable without +/// weakening the production caller's compile-time decision. +pub(crate) fn build_respond_to_env_with_policy( + record: &ManagedAgentRecord, + owner_hex: Option<&str>, + enforced_owner_only: bool, +) -> Result { + let (respond_to, _) = projected_access_with_policy(record, enforced_owner_only); + let normalized = validate_respond_to_allowlist(&record.respond_to_allowlist)?; + if respond_to == RespondTo::Allowlist && normalized.is_empty() { + return Err( + "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), + ); + } + + let mut set = vec![("BUZZ_ACP_RESPOND_TO", respond_to.as_str().to_string())]; + let mut remove = Vec::new(); + if enforced_owner_only { + set.push(( + "BUZZ_ACP_ALLOWED_RESPOND_TO", + RespondTo::OwnerOnly.as_str().to_string(), + )); + } else { + remove.push("BUZZ_ACP_ALLOWED_RESPOND_TO"); + } + if respond_to == RespondTo::Allowlist { + set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); + } else { + remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); + } + + if record.auth_tag.is_none() { + if let Some(owner) = owner_hex { + set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + } else { + remove.push("BUZZ_ACP_AGENT_OWNER"); + } + Ok((set, remove)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::BackendKind; + + fn record(backend: BackendKind) -> ManagedAgentRecord { + let mut record: ManagedAgentRecord = serde_json::from_value(serde_json::json!({ + "pubkey": "agent", "name": "Agent", "relay_url": "", "acp_command": "", + "agent_command": "", "agent_args": [], "mcp_command": "", + "turn_timeout_seconds": 0, "system_prompt": null, "created_at": "", + "updated_at": "", "last_started_at": null, "last_stopped_at": null, + "last_exit_code": null, "last_error": null + })) + .unwrap(); + record.backend = backend; + record.respond_to = RespondTo::Anyone; + record.respond_to_allowlist = vec!["a".repeat(64)]; + record + } + + #[test] + fn owner_only_access_policy_rejects_malformed_stored_allowlist_before_clamping() { + let mut record = record(BackendKind::Local); + record.respond_to_allowlist = vec!["malformed stale allowlist".into()]; + + let error = build_respond_to_env_with_policy(&record, Some("owner"), true) + .expect_err("owner-only access policy accepted a malformed stored allowlist"); + + assert!( + error.contains("invalid pubkey in respond-to allowlist"), + "owner-only access policy returned the wrong malformed-allowlist error: {error}", + ); + } + + #[test] + fn owner_only_access_enforcement_clamps_local_and_provider() { + for (label, backend) in [ + ("local", BackendKind::Local), + ( + "provider", + BackendKind::Provider { + id: "p".into(), + config: serde_json::json!({}), + }, + ), + ] { + let record = record(backend); + let (gate_set, _) = + build_respond_to_env_with_policy(&record, Some("owner"), true).unwrap(); + let gate_set: std::collections::HashMap<_, _> = gate_set.into_iter().collect(); + assert_eq!( + gate_set.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only runtime env did not clamp {label} agent", + ); + assert_eq!( + gate_set + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only runtime env omitted the {label} agent guard", + ); + + let (respond_to, allowlist) = projected_access_with_policy(&record, true); + assert_eq!( + respond_to, + RespondTo::OwnerOnly, + "owner-only provider payload did not clamp {label} agent", + ); + assert!( + allowlist.is_empty(), + "owner-only provider payload retained {label} agent allowlist", + ); + } + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_env.rs b/desktop/src-tauri/src/managed_agents/agent_env.rs index bf6bcb2298..05979e76cb 100644 --- a/desktop/src-tauri/src/managed_agents/agent_env.rs +++ b/desktop/src-tauri/src/managed_agents/agent_env.rs @@ -58,6 +58,19 @@ fn build_env_map( } } } + // Defense in depth. `build.rs` already refuses to bake a reserved key, so + // reaching this filter means the binary was produced by a build that + // skipped that check. Drop the key rather than let it override the access + // gate: the baked map is written into the spawned agent's environment last + // (see `managed_agents/runtime.rs`), so a baked `BUZZ_ACP_RESPOND_TO` would + // otherwise win over the gate Desktop just set. + map.retain(|key, _| { + if super::env_vars::is_reserved_env_key(key) { + eprintln!("buzz-desktop: ignoring reserved env var `{key}` from the baked build env"); + return false; + } + true + }); map } @@ -356,4 +369,66 @@ mod tests { "unrelated merged_env keys must pass through unchanged" ); } + + // ── baked reserved-key filtering ────────────────────────────────────── + // + // The baked map is written into a spawned agent's environment LAST (see + // `managed_agents/runtime.rs`), after Buzz sets the access gates. If a + // baked reserved key survived here, an internal build packaged with + // `BUZZ_ACP_RESPOND_TO=anyone` would answer anyone while the UI shows + // "Only me". `build.rs` rejects such a key at build time; these tests pin + // the runtime backstop for a binary built without that check. + + #[test] + fn build_env_map_drops_baked_access_gate_keys() { + use base64::Engine as _; + let raw = "BUZZ_ACP_RESPOND_TO=anyone\nBUZZ_ACP_ALLOWED_RESPOND_TO=anyone\nBUZZ_ACP_RESPOND_TO_ALLOWLIST=deadbeef\nDATABRICKS_MODEL=goose-claude-opus-4-8"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + ] { + assert!( + !map.contains_key(key), + "baked `{key}` must not reach the spawned agent env" + ); + } + assert_eq!( + map.get("DATABRICKS_MODEL").map(String::as_str), + Some("goose-claude-opus-4-8"), + "non-reserved baked keys must still pass through" + ); + } + + #[test] + fn build_env_map_drops_baked_reserved_keys_case_insensitively() { + use base64::Engine as _; + // `is_reserved_env_key` compares case-insensitively, and so must the + // baked filter: env lookup is case-sensitive on Unix, but a lowercase + // spelling would still be a reserved key smuggled past a case-sensitive + // check on Windows. + let raw = "buzz_acp_respond_to=anyone\nBuzz_Private_Key=nsec1fake"; + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "reserved keys in any casing must be dropped from the baked env: {map:?}" + ); + } + + #[test] + fn build_env_map_drops_every_reserved_key() { + use base64::Engine as _; + for key in super::super::env_vars::RESERVED_ENV_KEYS { + let raw = format!("{key}=baked-value"); + let blob = base64::engine::general_purpose::STANDARD.encode(raw.as_bytes()); + let map = build_env_map(None, None, Some(&blob)); + assert!( + map.is_empty(), + "baked reserved key `{key}` must be dropped, got {map:?}" + ); + } + } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index fafcb2589d..bc0e3a6cda 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1113,7 +1113,7 @@ pub fn missing_command_message(command: &str, role: &str) -> String { } format!( - "{role} `{command}` was not found. Build the workspace binaries (`cargo build --release --workspace`) or add `target/release` to PATH as described in TESTING.md." + "{role} `{command}` was not found. Make sure it is installed and on your PATH. Antivirus software can quarantine bundled binaries — if that happened, restore the file or reinstall Buzz. (Source builds: see TESTING.md.)" ) } @@ -1403,8 +1403,8 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr auth_status: AuthStatus::Unknown, login_hint: None, source: HarnessSource::Builtin, - // Builtin entries have no user-editable env; definition_env is empty. definition_env: Default::default(), + max_parallelism: super::parallelism::harness_max_parallelism(runtime.id), }, } } @@ -1565,9 +1565,8 @@ pub fn discover_acp_runtimes_from( auth_status: AuthStatus::NotApplicable, login_hint: None, source: HarnessSource::Custom, - // Carry definition env into the catalog so the edit form can - // read it back — prevents silently erasing env on save. - definition_env: def.env.clone(), + definition_env: def.env.clone(), // preserve for edit round-trip + max_parallelism: super::parallelism::harness_max_parallelism(&def.command), }); } } diff --git a/desktop/src-tauri/src/managed_agents/discovery/presets.rs b/desktop/src-tauri/src/managed_agents/discovery/presets.rs index b2d8a14ef0..d86e5f33f0 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/presets.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/presets.rs @@ -82,6 +82,10 @@ pub(super) fn preset_catalog_entry( login_hint: None, source: HarnessSource::Preset, definition_env: Default::default(), + // Derived from the static preset command (`def.command`). This ensures + // unavailable entries (command: null in JSON, None here) still carry + // the cap — the harness cap is command-keyed, not availability-gated. + max_parallelism: crate::managed_agents::harness_max_parallelism(def.command), } } @@ -405,4 +409,65 @@ mod tests { assert!(!entry.requires_external_cli); assert!(entry.underlying_cli_path.is_none()); } + + // ── Catalog max_parallelism: command-keyed execution policy ────────────── + + /// Unavailable OpenClaw (command not on PATH → command: null in JSON): + /// max_parallelism must still be Some(5) — derived from the static `def.command`, + /// not the probed `entry.command`. + #[test] + fn openclaw_preset_unavailable_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + // Simulate "not installed" — resolver always returns None. + let entry = preset_catalog_entry(openclaw, |_| None); + assert_eq!(entry.availability, AcpAvailabilityStatus::NotInstalled); + assert!( + entry.command.is_none(), + "unavailable entry must have command: null" + ); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "unavailable OpenClaw must still carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Available OpenClaw: max_parallelism present regardless of install status. + #[test] + fn openclaw_preset_available_carries_max_parallelism() { + let openclaw = PRESET_HARNESSES + .iter() + .find(|p| p.id == "openclaw") + .expect("openclaw preset must be present"); + + let entry = preset_catalog_entry(openclaw, |cmd| { + (cmd == openclaw.id || cmd == "openclaw") + .then(|| std::path::PathBuf::from("/usr/local/bin/openclaw")) + }); + assert_eq!( + entry.max_parallelism, + Some(crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM), + "available OpenClaw must carry max_parallelism {}", + crate::managed_agents::parallelism::OPENCLAW_MAX_PARALLELISM + ); + } + + /// Uncapped preset (devin): max_parallelism must be None. + #[test] + fn uncapped_preset_has_no_max_parallelism() { + let devin = PRESET_HARNESSES + .iter() + .find(|p| p.id == "devin") + .expect("devin preset must be present"); + let entry = preset_catalog_entry(devin, |_| None); + assert_eq!( + entry.max_parallelism, None, + "uncapped preset (devin) must have max_parallelism: None" + ); + } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 9956f19b29..9ca5fd080d 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -5,11 +5,13 @@ //! Precedence: desktop parent env < persona env < agent env (last wins on //! key collision). See `runtime::spawn_agent_child`. //! -//! A small set of *reserved* keys — Buzz's identity and secrets — are -//! rejected at save time and stripped at runtime so a typo or malicious -//! value can't swap the agent's nsec. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain -//! freely overridable — those have dedicated UI fields, but power users -//! may want to bypass them. +//! A small set of *reserved* keys includes Buzz's identity, secrets, security +//! gates, and control-plane values. Save-time validation rejects those keys. +//! Runtime filtering strips old persisted overrides. Behavior knobs +//! (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +//! overridable. Power users can still bypass their dedicated UI fields. +//! `BUZZ_ACP_AGENTS` is reserved because Desktop applies harness-specific caps +//! before it writes the provider launch policy. use std::collections::BTreeMap; @@ -39,68 +41,9 @@ pub(crate) fn is_derived_provider_model_key(key: &str) -> bool { .any(|k| k.eq_ignore_ascii_case(key)) } -/// Env var keys that Buzz sets itself and users must not override from -/// the persona/agent env_vars UI. Three categories: -/// -/// 1. **Identity / secrets** — overriding would swap the agent's nsec or -/// leak credentials. -/// 2. **Code-execution surface** — overriding the binary/args lets the -/// user run arbitrary code as the agent process. -/// 3. **Security gates** — overriding the respond-to mode/allowlist or -/// relay URL would silently break the saved security settings (the UI -/// shows owner-only while the running agent answers anyone, for -/// example), or redirect the agent to an attacker-controlled relay. -/// -/// This list is deliberately narrow — it only covers keys with security -/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely -/// overridable; those have dedicated UI fields but power users may want -/// to bypass them. -pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ - // Identity / secrets. - "BUZZ_PRIVATE_KEY", - "NOSTR_PRIVATE_KEY", - "BUZZ_AUTH_TAG", - "BUZZ_API_TOKEN", - "BUZZ_ACP_PRIVATE_KEY", - "BUZZ_ACP_API_TOKEN", - // Relay URL: overriding would let a malicious config redirect the - // agent to an attacker-controlled relay. - "BUZZ_RELAY_URL", - // Code-execution surface: overriding would let the user run arbitrary - // binaries/args as the agent process. - "BUZZ_ACP_AGENT_COMMAND", - "BUZZ_ACP_AGENT_ARGS", - "BUZZ_ACP_MCP_COMMAND", - // Security gates: respond-to mode + allowlist + legacy owner-only - // fallback. Overriding would make the running agent's gate diverge - // from the saved/UI-visible settings. - "BUZZ_ACP_RESPOND_TO", - "BUZZ_ACP_RESPOND_TO_ALLOWLIST", - "BUZZ_ACP_AGENT_OWNER", - // Stable agent identity used for git attribution and private-conversation - // provenance must come from the managed-agent record, not user overrides. - "BUZZ_ACP_DISPLAY_NAME", - // Remote lifetime/presence policy: user env must not disable the - // desktop/provider-owned bounds while the saved record still promises them. - "BUZZ_ACP_EXIT_AFTER_INACTIVITY", - "BUZZ_ACP_NO_PRESENCE", - // Readiness handoff: desktop is the ONLY readiness source. A saved or - // ambient env var must not be able to forge setup mode (NotReady) on a - // Ready agent or suppress it (empty/stale payload) on a NotReady one. - "BUZZ_ACP_SETUP_PAYLOAD", - // Desktop ownership markers: these brand every spawned harness with the - // launching Desktop instance. A user-supplied override would let a - // definition masquerade as a different instance or fake the nonce used - // for same-session sweep decisions. - "BUZZ_MANAGED_AGENT", - "BUZZ_MANAGED_AGENT_START_NONCE", -]; - -pub(crate) fn is_reserved_env_key(key: &str) -> bool { - RESERVED_ENV_KEYS - .iter() - .any(|reserved| reserved.eq_ignore_ascii_case(key)) -} +// Canonical reserved-key list + predicate, shared verbatim with `build.rs`. +// See `reserved_env_keys.rs` for why this is `include!`d rather than a module. +include!("reserved_env_keys.rs"); /// Returns true if `key` is a well-formed POSIX-shaped env var name: /// `[A-Za-z_][A-Za-z0-9_]*`. This is a hard requirement, not a stylistic diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index 534c2e0835..34cdfede2c 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -150,7 +150,11 @@ fn reserved_keys_include_respond_to_gate() { // Respond-to mode + allowlist control who the agent answers. // Overriding via env_vars would let the running agent answer // anyone even when the UI/record says owner-only. - for key in ["BUZZ_ACP_RESPOND_TO", "BUZZ_ACP_RESPOND_TO_ALLOWLIST"] { + for key in [ + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); let agent = map(&[(key, "anyone")]); let merged = merged_user_env(&BTreeMap::new(), &agent); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index a848b6f02f..fe90ce430f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,8 +1,10 @@ +pub(crate) mod access_policy; mod agent_env; pub(crate) mod agent_events; pub(crate) mod agent_snapshot; pub(crate) mod agent_snapshot_envelope; pub(crate) mod team_snapshot; +pub(crate) use access_policy::{owner_only, owner_only_access_build, projected_access_with_policy}; pub(crate) use agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, }; @@ -16,6 +18,7 @@ pub(crate) mod git_bash; pub(crate) mod global_config; mod managed_node_paths; mod nest; +pub(crate) mod parallelism; mod persona_avatars; pub(crate) mod persona_events; mod personas; @@ -59,6 +62,7 @@ pub(crate) use global_config::{ }; pub(crate) use managed_node_paths::*; pub use nest::*; +pub use parallelism::{acp_agents_value, effective_parallelism, harness_max_parallelism}; pub use personas::*; #[cfg(windows)] pub use process_lifecycle::*; diff --git a/desktop/src-tauri/src/managed_agents/parallelism.rs b/desktop/src-tauri/src/managed_agents/parallelism.rs new file mode 100644 index 0000000000..e1691575b1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/parallelism.rs @@ -0,0 +1,305 @@ +// ── Per-harness parallelism cap ─────────────────────────────────────────────── +// +// Contract: stored = requested; effective = min(requested, harness cap). +// +// `ManagedAgentRecord.parallelism` stores the user's requested value verbatim, +// never clamped at persistence. The cap is applied only where the value +// becomes a running worker-pool size: +// +// * local spawn — `BUZZ_ACP_AGENTS` in the child environment +// * remote deploy — `launch.policy_env["BUZZ_ACP_AGENTS"]` + legacy field +// * restart hash — `SpawnConfigSnapshot` stores the effective value +// * display copy — the UI derives effective for explanatory hints only +// +// `AgentDefinition.parallelism` is the portable requested value, unchanged +// at every boundary so it travels across devices and harness switches intact. + +/// Maximum parallelism for the OpenClaw harness. +/// +/// Each buzz-acp worker spawned by the Desktop is a client of the single +/// shared OpenClaw Gateway daemon — running more than this number of workers +/// is both resource-expensive and architecturally wrong per the OpenClaw +/// design. Tyler's ruling: "try 5 and lower if needed." +pub const OPENCLAW_MAX_PARALLELISM: u32 = 5; + +/// Return the maximum allowed parallelism for the given harness command, or +/// `None` when the harness has no cap. +/// +/// Keyed on [`super::discovery::normalize_command_identity`] so path prefixes, +/// the `.exe` suffix on Windows, and other cosmetic differences are ignored. +pub fn harness_max_parallelism(command: &str) -> Option { + match super::discovery::normalize_command_identity(command).as_str() { + "openclaw" => Some(OPENCLAW_MAX_PARALLELISM), + _ => None, + } +} + +/// Return the effective parallelism for the given harness command and +/// requested value: `min(value, harness_max_parallelism(command))`. +/// +/// For harnesses without a cap this is the identity function. +pub fn effective_parallelism(command: &str, value: u32) -> u32 { + match harness_max_parallelism(command) { + Some(cap) => value.min(cap), + None => value, + } +} + +/// Return the value to emit as `BUZZ_ACP_AGENTS` for a spawn command. +/// +/// Pure helper extracted from `spawn_agent_child` so both the production path +/// and tests can call it without spawning a process. The result is +/// `effective_parallelism(effective_command, record_parallelism)` formatted as +/// a decimal string ready for `command.env("BUZZ_ACP_AGENTS", …)`. +/// +/// `effective_command` must be the already-resolved harness command (override → +/// runtime → persona runtime → default). +pub fn acp_agents_value(effective_command: &str, record_parallelism: u32) -> String { + effective_parallelism(effective_command, record_parallelism).to_string() +} + +#[cfg(test)] +mod tests { + use crate::managed_agents::types::ManagedAgentRecord; + + fn record_with(runtime: Option<&str>, parallelism: u32) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: String::new(), + name: "r".to_string(), + persona_id: None, + private_key_nsec: String::new(), + auth_tag: None, + relay_url: String::new(), + avatar_url: None, + acp_command: String::new(), + agent_command: String::new(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 0, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + env_vars: std::collections::BTreeMap::new(), + created_at: String::new(), + updated_at: String::new(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to: Default::default(), + respond_to_allowlist: vec![], + display_name: None, + slug: None, + runtime: runtime.map(str::to_string), + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } + } + + fn persona_def( + id: &str, + runtime: Option<&str>, + ) -> crate::managed_agents::types::AgentDefinition { + use crate::managed_agents::types::AgentDefinition; + AgentDefinition { + id: id.to_string(), + display_name: String::new(), + avatar_url: None, + system_prompt: String::new(), + runtime: runtime.map(str::to_string), + model: None, + provider: None, + name_pool: vec![], + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + env_vars: std::collections::BTreeMap::new(), + respond_to: None, + respond_to_allowlist: vec![], + parallelism: None, + created_at: String::new(), + updated_at: String::new(), + } + } + + // ── Policy table: harness_max_parallelism / effective_parallelism ───────── + + #[test] + fn policy_table() { + let cap = super::OPENCLAW_MAX_PARALLELISM; + + // harness_max_parallelism: openclaw variants → Some(cap); others → None. + assert_eq!(super::harness_max_parallelism("openclaw"), Some(cap)); + assert_eq!( + super::harness_max_parallelism("/usr/local/bin/openclaw"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("openclaw.exe"), Some(cap)); + assert_eq!( + super::harness_max_parallelism(r"C:\Tools\openclaw.exe"), + Some(cap) + ); + assert_eq!(super::harness_max_parallelism("goose"), None); + assert_eq!(super::harness_max_parallelism("buzz-agent"), None); + assert_eq!(super::harness_max_parallelism(""), None); + + // effective_parallelism: openclaw clamps above cap, honors at/below; goose passes through. + assert_eq!(super::effective_parallelism("openclaw", cap + 5), cap); + assert_eq!(super::effective_parallelism("openclaw", cap), cap); + assert_eq!(super::effective_parallelism("openclaw", cap - 2), cap - 2); + assert_eq!(super::effective_parallelism("goose", 99), 99); + assert_eq!(super::effective_parallelism("buzz-agent", 32), 32); + } + + // ── acp_agents_value: spawn-env seam ────────────────────────────────────── + // + // Drives the pure helper extracted from spawn_agent_child. + // Deleting or changing it breaks this test AND the production spawn env. + + /// Legacy OpenClaw record (parallelism 10, above cap): BUZZ_ACP_AGENTS must be "5". + #[test] + fn acp_agents_value_openclaw_above_cap_is_capped() { + assert_eq!( + super::acp_agents_value("openclaw", 10), + "5", + "BUZZ_ACP_AGENTS for openclaw with parallelism 10 must be \"5\"" + ); + assert_eq!(super::acp_agents_value("goose", 10), "10"); + } + + // ── Override-direction: summary seam agreement ──────────────────────────── + // + // Tests effective_parallelism and record_agent_command agreement for both + // override directions. Removing either direction loses the seam test for + // that cap/uncap path through the summary resolver. + + /// OpenClaw runtime + Goose override: summary resolves goose → uncapped (10). + #[test] + fn override_direction_openclaw_runtime_goose_override_is_uncapped() { + let mut record = record_with(Some("openclaw"), 10); + record.agent_command_override = Some("goose".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "goose"); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Goose runtime + OpenClaw override: summary resolves openclaw → capped (5). + #[test] + fn override_direction_goose_runtime_openclaw_override_is_capped() { + let mut record = record_with(Some("goose"), 10); + record.agent_command_override = Some("openclaw".to_string()); + let cmd = crate::managed_agents::record_agent_command(&record, &[]); + assert_eq!(cmd, "openclaw"); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Summary: persona-inherited runtime (runtime=None) ───────────────────── + // + // Covers the case where runtime was cleared by an "inherit from persona" + // update: summary must resolve via the LIVE persona, not stale agent_command. + + /// Stale agent_command="openclaw", live persona=goose → summary resolves goose → uncapped. + #[test] + fn summary_persona_inherited_stale_openclaw_live_goose_is_uncapped() { + let persona = persona_def("p-goose", Some("goose")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-goose".to_string()); + record.agent_command = "openclaw".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "goose", + "live persona must win over stale agent_command" + ); + assert_eq!(super::effective_parallelism(&cmd, record.parallelism), 10); + } + + /// Stale agent_command="goose", live persona=openclaw → summary resolves openclaw → capped. + #[test] + fn summary_persona_inherited_stale_goose_live_openclaw_is_capped() { + let persona = persona_def("p-openclaw", Some("openclaw")); + let mut record = record_with(None, 10); + record.persona_id = Some("p-openclaw".to_string()); + record.agent_command = "goose".to_string(); + let cmd = + crate::managed_agents::record_agent_command(&record, std::slice::from_ref(&persona)); + assert_eq!( + cmd, "openclaw", + "live persona must win over stale agent_command" + ); + assert_eq!( + super::effective_parallelism(&cmd, record.parallelism), + super::OPENCLAW_MAX_PARALLELISM + ); + } + + // ── Snapshot export: requested-definition / effective-instance contract ─── + + fn snapshot_record( + runtime: Option<&str>, + parallelism: u32, + definition_parallelism: Option, + ) -> ManagedAgentRecord { + use crate::managed_agents::types::{BackendKind, RespondTo}; + use std::collections::BTreeMap; + let mut r = record_with(runtime, parallelism); + r.name = "snap-test".to_string(); + r.definition_parallelism = definition_parallelism; + r.backend = BackendKind::Local; + r.respond_to = RespondTo::OwnerOnly; + r.env_vars = BTreeMap::new(); + r + } + + /// Snapshot export carries the requested definition parallelism verbatim. + #[test] + fn snapshot_export_carries_requested_definition_parallelism() { + use crate::managed_agents::agent_snapshot::{build_snapshot, MemoryLevel}; + // definition_parallelism=Some(10) stored → exported as 10 unchanged. + let snap = build_snapshot( + &snapshot_record(Some("openclaw"), 10, Some(10)), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap.definition.parallelism, Some(10)); + // No definition_parallelism stored → falls back to record.parallelism. + let snap2 = build_snapshot( + &snapshot_record(Some("openclaw"), 10, None), + MemoryLevel::None, + vec![], + None, + ); + assert_eq!(snap2.definition.parallelism, Some(10)); + } +} diff --git a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs index c34ab1739b..2bd7ba3d1c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events/stale_pin_tests.rs @@ -99,3 +99,53 @@ fn apply_persona_snapshot_same_harness_path_pin_is_kept() { "same-harness path override must NOT be dropped" ); } + +// ── Stale-pin drop: builtin pin → loaded custom harness (tier-1→tier-3) ────── + +/// Persona→CustomHarness: stale Goose override dropped. +/// +/// This is the custom-direction regression: before `canonical_harness_command` +/// the destination lookup (`known_acp_runtime_exact`) only saw the four +/// tier-1 builtins, so a switch to a loaded custom harness left any stale +/// builtin pin authoritative. +/// +/// Tier-3 (loaded custom harness) is reached via `lookup_loaded_harness_by_id`, +/// which reads the in-process registry — so we must populate it via +/// `update_loaded_harness_registry` under `registry_test_lock()`. +#[test] +fn apply_persona_snapshot_goose_to_custom_harness_drops_stale_goose_pin() { + use crate::managed_agents::custom_harnesses::{ + registry_test_lock, update_loaded_harness_registry, HarnessDefinition, + }; + use std::collections::BTreeMap; + + let _lock = registry_test_lock(); + + // Register a custom harness definition so the resolver finds it at tier 3. + update_loaded_harness_registry(vec![HarnessDefinition { + id: "my-custom-harness".to_string(), + label: "My Custom Harness".to_string(), + command: "my-custom-bin".to_string(), + args: vec![], + env: BTreeMap::new(), + install_instructions_url: String::new(), + install_hint: String::new(), + }]); + + let mut record = sample_record(); + record.agent_command_override = Some("goose".to_string()); + apply_persona_snapshot( + &mut record, + &AgentDefinition { + runtime: Some("my-custom-harness".to_string()), + ..sample_persona() + }, + ); + assert_eq!( + record.agent_command_override, None, + "stale goose pin must be dropped when persona switches to a loaded custom harness" + ); + + // Clean up the registry so parallel tests start from a known state. + update_loaded_harness_registry(vec![]); +} diff --git a/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs new file mode 100644 index 0000000000..8698d3a51d --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/reserved_env_keys.rs @@ -0,0 +1,79 @@ +// Canonical reserved-env-key list, `include!`d into BOTH `build.rs` +// (compile-time rejection of baked `BUZZ_BUILD_AGENT_ENV` collisions) and +// `managed_agents/env_vars.rs` (save-time validation and spawn-time +// filtering). Build scripts cannot import from the crate, so sharing the +// source via `include!` is what guarantees the build-time check and the +// runtime filter use one identical list — zero drift surface. See +// `commands/reconnect_hook_config.rs` for the same pattern. +// +// Keep this file dependency-free: no crate-internal imports, no external +// crates. Both consumers compile it as-is. + +/// Env var keys that Buzz sets itself and users must not override from +/// the persona/agent env_vars UI. Three categories: +/// +/// 1. **Identity / secrets** — overriding would swap the agent's nsec or +/// leak credentials. +/// 2. **Code-execution surface** — overriding the binary/args lets the +/// user run arbitrary code as the agent process. +/// 3. **Security gates** — overriding the respond-to mode/allowlist or +/// relay URL would silently break the saved security settings (the UI +/// shows owner-only while the running agent answers anyone, for +/// example), or redirect the agent to an attacker-controlled relay. +/// +/// This list is deliberately narrow — it only covers keys with security +/// implications. Behavior knobs (GOOSE_MODE, BUZZ_ACP_MODEL, BUZZ_ACP_SYSTEM_PROMPT, …) remain freely +/// overridable; those have dedicated UI fields but power users may want +/// to bypass them. +pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ + // Identity / secrets. + "BUZZ_PRIVATE_KEY", + "NOSTR_PRIVATE_KEY", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_ACP_API_TOKEN", + // Relay URL: overriding would let a malicious config redirect the + // agent to an attacker-controlled relay. + "BUZZ_RELAY_URL", + // Code-execution surface: overriding would let the user run arbitrary + // binaries/args as the agent process. + "BUZZ_ACP_AGENT_COMMAND", + "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_MCP_COMMAND", + // Control-plane parallelism: the Desktop resolves the effective + // worker-pool size (applying any per-harness cap) and writes it into + // launch.policy_env. A user-supplied BUZZ_ACP_AGENTS would bypass the + // harness cap and cause OpenClaw agents to spawn uncapped workers. + "BUZZ_ACP_AGENTS", + // Security gates: respond-to mode + allowlist + deployment allowlist + + // legacy owner-only fallback. Overriding would make the running agent's + // gate diverge from the saved/UI-visible settings. + "BUZZ_ACP_RESPOND_TO", + "BUZZ_ACP_RESPOND_TO_ALLOWLIST", + "BUZZ_ACP_ALLOWED_RESPOND_TO", + "BUZZ_ACP_AGENT_OWNER", + // Stable agent identity used for git attribution and private-conversation + // provenance must come from the managed-agent record, not user overrides. + "BUZZ_ACP_DISPLAY_NAME", + // Remote lifetime/presence policy: user env must not disable the + // desktop/provider-owned bounds while the saved record still promises them. + "BUZZ_ACP_EXIT_AFTER_INACTIVITY", + "BUZZ_ACP_NO_PRESENCE", + // Readiness handoff: desktop is the ONLY readiness source. A saved or + // ambient env var must not be able to forge setup mode (NotReady) on a + // Ready agent or suppress it (empty/stale payload) on a NotReady one. + "BUZZ_ACP_SETUP_PAYLOAD", + // Desktop ownership markers: these brand every spawned harness with the + // launching Desktop instance. A user-supplied override would let a + // definition masquerade as a different instance or fake the nonce used + // for same-session sweep decisions. + "BUZZ_MANAGED_AGENT", + "BUZZ_MANAGED_AGENT_START_NONCE", +]; + +pub(crate) fn is_reserved_env_key(key: &str) -> bool { + RESERVED_ENV_KEYS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(key)) +} diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 9fa9e0cce6..ec804869c4 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -16,9 +16,9 @@ use crate::{ mod path; pub(in crate::managed_agents) use path::build_augmented_path; -pub(crate) use path::compose_path_entries; -pub(crate) use path::should_skip_claude_executable; -pub(crate) use path::should_use_inherited; +pub(crate) use path::{compose_path_entries, should_skip_claude_executable, should_use_inherited}; + +pub(crate) use super::access_policy::{build_respond_to_env_with_policy, RespondToEnv}; mod metadata; pub(crate) use metadata::{ @@ -33,8 +33,6 @@ pub use stop::{stop_managed_agent_process, stop_managed_agent_workspace_pair}; mod sweep; pub(crate) use sweep::sweep_untracked_bundle_harnesses; -type RespondToEnv = (Vec<(&'static str, String)>, Vec<&'static str>); - mod process; #[cfg(test)] use process::{ @@ -370,44 +368,7 @@ pub(crate) fn build_respond_to_env( record: &ManagedAgentRecord, owner_hex: Option<&str>, ) -> Result { - // Defensive re-validation: an on-disk record could have been hand-edited. - let normalized = super::types::validate_respond_to_allowlist(&record.respond_to_allowlist)?; - if record.respond_to == super::types::RespondTo::Allowlist && normalized.is_empty() { - return Err( - "respond-to mode 'allowlist' requires at least one pubkey in the allowlist".to_string(), - ); - } - - let mut set: Vec<(&'static str, String)> = Vec::new(); - let mut remove: Vec<&'static str> = Vec::new(); - - set.push(( - "BUZZ_ACP_RESPOND_TO", - record.respond_to.as_str().to_string(), - )); - - if record.respond_to == super::types::RespondTo::Allowlist { - set.push(("BUZZ_ACP_RESPOND_TO_ALLOWLIST", normalized.join(","))); - } else { - remove.push("BUZZ_ACP_RESPOND_TO_ALLOWLIST"); - } - - // Legacy fallback: agents created before NIP-OA lack `auth_tag`. Without - // it the harness can't resolve the owner, and owner-dependent gate modes - // would drop every event. Forwarding the workspace owner pubkey via - // BUZZ_ACP_AGENT_OWNER keeps those records functional. Modern records - // (`auth_tag = Some(...)`) use `BUZZ_AUTH_TAG` as before. - if record.auth_tag.is_none() { - if let Some(owner) = owner_hex { - set.push(("BUZZ_ACP_AGENT_OWNER", owner.to_string())); - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - } else { - remove.push("BUZZ_ACP_AGENT_OWNER"); - } - - Ok((set, remove)) + build_respond_to_env_with_policy(record, owner_hex, super::owner_only()) } pub(crate) fn configure_runtime_cli( @@ -703,12 +664,9 @@ pub fn spawn_agent_child( ); } } - // Only emit BUZZ_ACP_IDLE_TIMEOUT when the user has explicitly set an - // override. When unset, the buzz-acp harness applies its own default - // (see `DEFAULT_IDLE_TIMEOUT_SECS` in crates/buzz-acp/src/config.rs), - // which is the single source of truth. The previously-emitted - // `BUZZ_ACP_TURN_TIMEOUT` is deprecated upstream and was pinning every - // agent to the desktop's stale default (320s), bypassing harness bumps. + // Emit BUZZ_ACP_IDLE_TIMEOUT only when explicitly set; the harness + // DEFAULT_IDLE_TIMEOUT_SECS is the single source of truth. The deprecated + // BUZZ_ACP_TURN_TIMEOUT pinned agents to a stale default (320s). if let Some(idle) = record.idle_timeout_seconds { command.env("BUZZ_ACP_IDLE_TIMEOUT", idle.to_string()); } @@ -716,7 +674,8 @@ pub fn spawn_agent_child( if let Some(max_dur) = record.max_turn_duration_seconds { command.env("BUZZ_ACP_MAX_TURN_DURATION", max_dur.to_string()); } - command.env("BUZZ_ACP_AGENTS", record.parallelism.to_string()); + let acp_n = super::acp_agents_value(effective_command, record.parallelism); + command.env("BUZZ_ACP_AGENTS", acp_n); command.env("BUZZ_ACP_MULTIPLE_EVENT_HANDLING", "steer"); command.env("BUZZ_ACP_DEDUP", "queue"); if let Some(meta) = runtime_meta { @@ -1017,5 +976,8 @@ pub fn start_managed_agent_process( Ok(()) } +#[cfg(test)] +mod test_fixtures; + #[cfg(test)] mod tests; diff --git a/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs new file mode 100644 index 0000000000..9836d983ed --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/runtime/test_fixtures.rs @@ -0,0 +1,93 @@ +use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; + +pub(super) const EXPECTED_ACCESS_ENV: &str = "BUZZ_TEST_EXPECTED_AGENT_ACCESS_OWNER_ONLY"; + +pub(super) fn expected_owner_only() -> bool { + match std::env::var(EXPECTED_ACCESS_ENV) { + Ok(value) => value + .parse::() + .unwrap_or_else(|_| panic!("{EXPECTED_ACCESS_ENV} must be true or false")), + Err(std::env::VarError::NotPresent) + if !crate::managed_agents::owner_only_access_build() => + { + false + } + Err(std::env::VarError::NotPresent) => { + panic!("{EXPECTED_ACCESS_ENV} must be set for owner-only-access-build tests") + } + Err(std::env::VarError::NotUnicode(_)) => { + panic!("{EXPECTED_ACCESS_ENV} must be valid UTF-8") + } + } +} + +pub(super) fn expected_mode(oss_mode: &'static str) -> &'static str { + if expected_owner_only() { + "owner-only" + } else { + oss_mode + } +} + +/// Construct a minimal record fixture for runtime tests. +pub(super) fn fixture( + respond_to: RespondTo, + allowlist: Vec, + auth_tag: Option, +) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "p".into(), + name: "n".into(), + persona_id: None, + private_key_nsec: "nsec1fake".into(), + auth_tag, + relay_url: "ws://localhost:3000".into(), + avatar_url: None, + acp_command: "buzz-acp".into(), + agent_command: "goose".into(), + agent_command_override: None, + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 320, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + provider: None, + persona_source_version: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: Default::default(), + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + created_at: "now".into(), + updated_at: "now".into(), + last_started_at: None, + last_stopped_at: None, + last_exit_code: None, + last_error: None, + last_error_code: None, + respond_to, + respond_to_allowlist: allowlist, + display_name: None, + slug: None, + runtime: None, + name_pool: Vec::new(), + is_builtin: false, + is_active: true, + shared: false, + source_team: None, + source_team_persona_slug: None, + catalog_source: None, + definition_respond_to: None, + definition_respond_to_allowlist: Vec::new(), + definition_parallelism: None, + relay_mesh: None, + } +} diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index bea4b1c3e3..762b0fe2a6 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -117,73 +117,10 @@ fn unknown_command_returns_none() { // ── build_respond_to_env tests ─────────────────────────────────────── -use super::build_respond_to_env; +use super::test_fixtures::{expected_mode, expected_owner_only, fixture}; +use super::{build_respond_to_env, build_respond_to_env_with_policy}; use crate::managed_agents::types::{ManagedAgentRecord, RespondTo}; -/// Construct a minimal record fixture for env-building tests. Only the -/// fields read by `build_respond_to_env` matter here. -fn fixture( - respond_to: RespondTo, - allowlist: Vec, - auth_tag: Option, -) -> ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "p".into(), - name: "n".into(), - persona_id: None, - private_key_nsec: "nsec1fake".into(), - auth_tag, - relay_url: "ws://localhost:3000".into(), - avatar_url: None, - acp_command: "buzz-acp".into(), - agent_command: "goose".into(), - agent_command_override: None, - agent_args: vec![], - mcp_command: String::new(), - turn_timeout_seconds: 320, - idle_timeout_seconds: None, - max_turn_duration_seconds: None, - parallelism: 1, - system_prompt: None, - model: None, - provider: None, - persona_source_version: None, - env_vars: std::collections::BTreeMap::new(), - start_on_app_launch: false, - auto_restart_on_config_change: true, - runtime_pid: None, - backend: Default::default(), - backend_agent_id: None, - provider_binary_path: None, - team_id: None, - persona_team_dir: None, - persona_name_in_team: None, - created_at: "now".into(), - updated_at: "now".into(), - last_started_at: None, - last_stopped_at: None, - last_exit_code: None, - last_error: None, - last_error_code: None, - respond_to, - respond_to_allowlist: allowlist, - display_name: None, - slug: None, - runtime: None, - name_pool: Vec::new(), - is_builtin: false, - is_active: true, - shared: false, - source_team: None, - source_team_persona_slug: None, - catalog_source: None, - definition_respond_to: None, - definition_respond_to_allowlist: Vec::new(), - definition_parallelism: None, - relay_mesh: None, - } -} - #[test] fn build_env_owner_only_sets_mode_and_removes_others() { let rec = fixture(RespondTo::OwnerOnly, vec![], Some("tag".into())); @@ -195,6 +132,18 @@ fn build_env_owner_only_sets_mode_and_removes_others() { ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + if expected_owner_only() { + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only") + ); + assert!(!remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } else { + assert!(!set_map.contains_key("BUZZ_ACP_ALLOWED_RESPOND_TO")); + assert!(remove.contains(&"BUZZ_ACP_ALLOWED_RESPOND_TO")); + } // auth_tag is present → no AGENT_OWNER fallback fires. assert!(remove.contains(&"BUZZ_ACP_AGENT_OWNER")); } @@ -214,14 +163,19 @@ fn build_env_allowlist_sets_both_envs_and_joins() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("allowlist") - ); - assert_eq!( - set_map - .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") - .map(String::as_str), - Some(format!("{a},{b}").as_str()), + Some(expected_mode("allowlist")), + "runtime wrapper did not apply the declared build policy", ); + if expected_owner_only() { + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + } else { + assert_eq!( + set_map + .get("BUZZ_ACP_RESPOND_TO_ALLOWLIST") + .map(String::as_str), + Some(format!("{a},{b}").as_str()), + ); + } } #[test] @@ -231,7 +185,30 @@ fn build_env_anyone_omits_allowlist_var() { let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); assert_eq!( set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), - Some("anyone") + Some(expected_mode("anyone")), + "runtime wrapper did not apply the declared build policy", + ); + assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); + assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); +} + +#[test] +fn owner_only_access_policy_overrides_stale_anyone_record_at_runtime() { + let rec = fixture(RespondTo::Anyone, vec!["a".repeat(64)], Some("tag".into())); + let (set, remove) = build_respond_to_env_with_policy(&rec, Some("owner"), true).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env widened stale access", + ); + assert_eq!( + set_map + .get("BUZZ_ACP_ALLOWED_RESPOND_TO") + .map(String::as_str), + Some("owner-only"), + "owner-only-access runtime env omitted the owner-only guard", ); assert!(!set_map.contains_key("BUZZ_ACP_RESPOND_TO_ALLOWLIST")); assert!(remove.contains(&"BUZZ_ACP_RESPOND_TO_ALLOWLIST")); @@ -271,8 +248,17 @@ fn build_env_rejects_corrupted_allowlist() { #[test] fn build_env_rejects_empty_allowlist_in_allowlist_mode() { let rec = fixture(RespondTo::Allowlist, vec![], Some("tag".into())); - let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); - assert!(err.contains("at least one pubkey")); + if expected_owner_only() { + let (set, _) = build_respond_to_env(&rec, Some("owner")).unwrap(); + let set_map: std::collections::HashMap<_, _> = set.into_iter().collect(); + assert_eq!( + set_map.get("BUZZ_ACP_RESPOND_TO").map(String::as_str), + Some("owner-only") + ); + } else { + let err = build_respond_to_env(&rec, Some("owner")).unwrap_err(); + assert!(err.contains("at least one pubkey")); + } } // ── persona fixture helpers ───────────────────────────────────────── diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs index 73a006e70f..ba2129c984 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot.rs @@ -167,7 +167,13 @@ impl SpawnConfigSnapshot { ), idle_timeout_seconds: record.idle_timeout_seconds, max_turn_duration_seconds: record.max_turn_duration_seconds, - parallelism: record.parallelism, + // Hash the effective parallelism so over-cap edits that don't change + // the running pool size (e.g. 10 → 8, both clamp to 5 on OpenClaw) + // do not raise a spurious "restart required" badge. Cap crossings + // (e.g. 8 → 3, where 3 is below the cap) do change the effective + // pool and must badge. The diff surface consequently displays the + // effective value — that is correct, it is what actually runs. + parallelism: super::effective_parallelism(&descriptor.command, record.parallelism), } } diff --git a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs index d76605ecff..1ceeee372f 100644 --- a/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs +++ b/desktop/src-tauri/src/managed_agents/spawn_snapshot/tests.rs @@ -778,3 +778,52 @@ fn spawn_snapshot_instance_args_win_over_definition_args() { "instance args and definition args must produce different snapshots" ); } + +// ── Parallelism cap: above-cap equivalence + cap crossing ───────────────────── +// +// The snapshot stores the *effective* parallelism (min(requested, harness cap)) +// so that over-cap edits that don't change the running pool size do not raise a +// spurious "restart required" badge, while cap crossings (e.g. 8 → 3, where 3 +// is below the cap) still badge because the pool actually changes. + +/// Two over-cap parallelism values (10 and 8) produce the same snapshot for +/// OpenClaw: both clamp to OPENCLAW_MAX_PARALLELISM (5). +#[test] +fn openclaw_above_cap_parallelism_snapshots_equal() { + let mut at_10 = record(); + at_10.runtime = Some("openclaw".into()); + at_10.agent_command = "openclaw".into(); + at_10.parallelism = 10; + + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + assert_eq!( + snapshot(&at_10, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 10 and 8 both clamp to 5 for OpenClaw — snapshots must be equal, no restart badge" + ); +} + +/// A cap-crossing edit (8 → 3) produces different snapshots: 8 clamps to 5, +/// but 3 is below the cap and runs as 3 — the pool changes, so the badge fires. +#[test] +fn openclaw_cap_crossing_parallelism_snapshots_differ() { + let mut at_8 = record(); + at_8.runtime = Some("openclaw".into()); + at_8.agent_command = "openclaw".into(); + at_8.parallelism = 8; + + let mut at_3 = record(); + at_3.runtime = Some("openclaw".into()); + at_3.agent_command = "openclaw".into(); + at_3.parallelism = 3; + + assert_ne!( + snapshot(&at_8, &[], &[], "wss://ws.example", &Default::default()), + snapshot(&at_3, &[], &[], "wss://ws.example", &Default::default()), + "parallelism 8 (clamps to 5) and 3 (runs as 3) must produce different snapshots" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index c5bb6173d1..e5be105fed 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -663,16 +663,14 @@ pub struct AcpRuntimeCatalogEntry { /// Whether this entry came from the compiled-in catalog or a user-supplied /// JSON file in `custom_harnesses/`. The UI uses this to decide editability. pub source: HarnessSource, - /// Definition-level environment variables for `source: custom` entries. - /// - /// Populated from `HarnessDefinition.env` so the edit form can read them - /// back and the user doesn't silently lose env vars when saving. Always - /// empty for `builtin` and `preset` entries (those env values come from the - /// runtime metadata path, not user-editable JSON). - /// - /// Skipped in serialization when empty to keep the catalog payload compact. + /// Definition-level env vars for `source: custom` entries; populated from + /// `HarnessDefinition.env` so saves don't silently erase existing vars. + /// Absent for builtin/preset entries. Skipped when empty in serialization. #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub definition_env: BTreeMap, + /// Spawn-time parallelism cap; absent for uncapped harnesses. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_parallelism: Option, } /// Result of a single install step (CLI or adapter). diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index d033ca0e01..e57bffd9db 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,7 +36,7 @@ ], "macOSPrivateApi": true, "security": { - "csp": null + "csp": "default-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'wasm-unsafe-eval' https://cdn.jsdelivr.net/npm/@mediapipe/; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost buzz-media: http://buzz-media.localhost https: http: wss: ws:; img-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; media-src 'self' buzz-media: http://buzz-media.localhost data: blob: https: http:; worker-src 'self' blob:" } }, "plugins": { diff --git a/desktop/src-tauri/tests/csp.rs b/desktop/src-tauri/tests/csp.rs new file mode 100644 index 0000000000..a8cc880e41 --- /dev/null +++ b/desktop/src-tauri/tests/csp.rs @@ -0,0 +1,201 @@ +//! Guards on the packaged-app Content-Security-Policy in `tauri.conf.json`. +//! +//! The CSP is only enforced on assets Tauri itself serves, so neither +//! `just dev` (loads the Vite `devUrl`) nor the Playwright suite (runs under +//! `vite preview`) can catch a policy that breaks the app. These tests pin the +//! non-obvious sources the frontend actually needs, so a future tightening +//! fails here instead of in a signed build. +//! +//! Kept as an integration test so the policy can be checked without the app +//! crate having to declare a test-only module. + +use std::collections::HashMap; + +const TAURI_CONF: &str = include_str!("../tauri.conf.json"); + +fn csp_directives() -> HashMap> { + let conf: serde_json::Value = + serde_json::from_str(TAURI_CONF).expect("tauri.conf.json is valid JSON"); + let csp = conf["app"]["security"]["csp"] + .as_str() + .expect("app.security.csp is set as a policy string"); + + csp.split(';') + .filter_map(|directive| { + let mut parts = directive.split_whitespace(); + let name = parts.next()?; + Some((name.to_owned(), parts.map(str::to_owned).collect())) + }) + .collect() +} + +fn sources(directive: &str) -> Vec { + csp_directives() + .remove(directive) + .unwrap_or_else(|| panic!("csp is missing the {directive} directive")) +} + +#[test] +fn script_src_allows_wasm_instantiation() { + // Shiki's default engine (Oniguruma) instantiates inlined WebAssembly for + // every code block; MediaPipe selfie segmentation does the same. Without + // this token both silently degrade — highlighting drops to plain text and + // animated avatars keep their background. + assert!(sources("script-src").contains(&"'wasm-unsafe-eval'".to_owned())); +} + +/// The `MEDIAPIPE_WASM_BASE` literal the frontend hands to `FilesetResolver`. +fn mediapipe_wasm_base() -> String { + const CAPTURE: &str = include_str!("../../src/features/profile/lib/animatedAvatarCapture.ts"); + + let after = CAPTURE + .split_once("const MEDIAPIPE_WASM_BASE =") + .expect("animatedAvatarCapture.ts declares MEDIAPIPE_WASM_BASE") + .1; + let url = after + .split_once('"') + .expect("MEDIAPIPE_WASM_BASE is a double-quoted string literal") + .1; + url.split_once('"') + .expect("MEDIAPIPE_WASM_BASE literal is terminated") + .0 + .to_owned() +} + +/// The npm scope the MediaPipe loader must come from. A CSP source ending in +/// `/` is a path *prefix* — paths can't be wildcarded — so this admits any +/// `@mediapipe` package while excluding the rest of what jsDelivr serves. +const MEDIAPIPE_SCOPE: &str = "https://cdn.jsdelivr.net/npm/@mediapipe/"; + +#[test] +fn script_src_scopes_the_mediapipe_loader() { + // `FilesetResolver.forVisionTasks` loads `vision_wasm[_nosimd]_internal.js` + // via a `