From 73589408db6fd96b87ac570935d414ecc4120f53 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 15:58:48 +0100 Subject: [PATCH 01/87] fix: align responsive agent views (#3688) ## Summary - Keep the Agents header full width while cards reflow independently. - Collapse header actions into an overflow menu at the compact layout threshold. - Apply the same responsive grid rules to Agent Teams. ## Validation - `pnpm -C desktop build:e2e` - Focused Agents Playwright coverage - Pre-push desktop checks and unit tests --------- Signed-off-by: kenny lopez --- desktop/src/features/agents/ui/AgentsView.tsx | 124 ++++++++++++----- .../src/features/agents/ui/TeamsSection.tsx | 6 +- .../agents/ui/UnifiedAgentsSection.tsx | 8 +- desktop/tests/e2e/agents.spec.ts | 129 +++++++++++++++++- 4 files changed, 223 insertions(+), 44 deletions(-) diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index f24a3c06d7..3d1673c365 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { OctagonX, Settings2 } from "lucide-react"; +import { EllipsisVertical, OctagonX, Settings2 } from "lucide-react"; import { consumePendingSnapshotImport, subscribeSnapshotImport, @@ -20,10 +20,7 @@ import { SecretRevealDialog } from "./SecretRevealDialog"; import { TeamDeleteDialog } from "./TeamDeleteDialog"; import { TeamDialog } from "./TeamDialog"; import { TeamsSection } from "./TeamsSection"; -import { - AGENT_CARD_GRID_COLUMNS_CLASS, - UnifiedAgentsSection, -} from "./UnifiedAgentsSection"; +import { UnifiedAgentsSection } from "./UnifiedAgentsSection"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -32,6 +29,12 @@ import { useBakedBuildEnvQuery } from "@/features/agents/hooks"; import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions"; import { useGlobalAgentConfig } from "@/features/agents/useGlobalAgentConfig"; import { Button } from "@/shared/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/shared/ui/dropdown-menu"; import { PageHeader } from "@/shared/ui/PageHeader"; import { getInheritedAgentDefaults } from "./bakedEnvHelpers"; @@ -44,6 +47,8 @@ export function AgentsView() { const personas = usePersonaActions(); const teamImportInputRef = React.useRef(null); const aiDefaultsTriggerRef = React.useRef(null); + const fullAiDefaultsTriggerRef = React.useRef(null); + const compactActionsTriggerRef = React.useRef(null); const [isAiDefaultsOpen, setIsAiDefaultsOpen] = React.useState(false); // Exclusivity: create never sets `personaDialogState` (edit/dup/import do), // so the create-mode and definition-edit AgentDialog mounts never coexist. @@ -53,6 +58,22 @@ export function AgentsView() { personas.prepareCreate(); setIsCreateDialogOpen(true); } + + function openAiDefaults(trigger: HTMLButtonElement | null) { + aiDefaultsTriggerRef.current = trigger; + setIsAiDefaultsOpen(true); + } + + function setAiDefaultsDialogOpen(open: boolean) { + if (!open) { + aiDefaultsTriggerRef.current = + fullAiDefaultsTriggerRef.current?.offsetParent !== null + ? fullAiDefaultsTriggerRef.current + : compactActionsTriggerRef.current; + } + setIsAiDefaultsOpen(open); + } + const teamActions = useTeamActions( { setActionNoticeMessage: agents.setActionNoticeMessage, @@ -113,43 +134,84 @@ export function AgentsView() { <>
- - {runningAgentCount > 0 ? ( + <> +
- ) : null} -
+ {runningAgentCount > 0 ? ( + + ) : null} +
+ + + + + + + { + openAiDefaults(compactActionsTriggerRef.current); + }} + > + + {hasSavedAgentDefaults + ? "Agent defaults" + : "Set agent defaults"} + + {runningAgentCount > 0 ? ( + { + void agents.handleBulkStopRunning(); + }} + > + + Stop running agents + + ) : null} + + + } description="Set up and manage your agents." title="Agents" /> -
+
diff --git a/desktop/src/features/agents/ui/TeamsSection.tsx b/desktop/src/features/agents/ui/TeamsSection.tsx index f974081b7d..c5a7a078b3 100644 --- a/desktop/src/features/agents/ui/TeamsSection.tsx +++ b/desktop/src/features/agents/ui/TeamsSection.tsx @@ -20,9 +20,9 @@ import { IdentityCardSkeleton } from "@/shared/ui/identity-card-skeleton"; import { SectionHeader } from "@/shared/ui/PageHeader"; import { CreateIdentityCard } from "./CreateIdentityCard"; import { TeamIdentityCard } from "./TeamIdentityCard"; +import { IDENTITY_CARD_GRID_CLASS } from "./UnifiedAgentsSection"; const TEAM_CARD_COLUMN_CLASS = "w-full"; -const TEAM_CARD_GRID_CLASS = `${TEAM_CARD_COLUMN_CLASS} mx-auto grid max-w-[996px] grid-cols-[repeat(auto-fill,minmax(220px,240px))] justify-center gap-3`; type TeamsSectionProps = { teams: AgentTeam[]; @@ -63,7 +63,7 @@ export function TeamsSection({
{isLoading ? ( -
+
+
{teams.map((team) => { const resolution = resolveTeamPersonas(team, personas); const missingPersonaCount = resolution.missingPersonaCount; diff --git a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx index 9bbe3feef7..19a5ef1171 100644 --- a/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/UnifiedAgentsSection.tsx @@ -68,7 +68,7 @@ type UnifiedAgentsSectionProps = { const AGENT_CARD_COLUMN_CLASS = "w-full"; export const AGENT_CARD_GRID_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(220px,240px))]"; -const AGENT_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3`; +export const IDENTITY_CARD_GRID_CLASS = `${AGENT_CARD_COLUMN_CLASS} ${AGENT_CARD_GRID_COLUMNS_CLASS} grid justify-start gap-3 [@container(max-width:40rem)]:justify-center`; export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { const { @@ -153,7 +153,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) { {!isLoading ? (
-
+
{groups.map((group) => { const profileAgent = pickProfileAgent(group.agents); return ( @@ -479,7 +479,7 @@ function NewAgentCard({ function LoadingSkeleton() { return ( -
+
({agents.length}) {!isCollapsed ? ( -
+
{agents.map((agent) => ( cards.map((card) => { const box = card.getBoundingClientRect(); - return { right: box.right, top: box.top }; + return { left: box.left, right: box.right, top: box.top }; }), ); const firstRowTop = Math.min(...cardBoxes.map(({ top }) => top)); @@ -425,12 +425,16 @@ test("the new agent card offers create, discover, and import", async ({ .filter(({ top }) => Math.abs(top - firstRowTop) < 1) .map(({ right }) => right), ); + const leftmostFirstRowCard = Math.min( + ...cardBoxes + .filter(({ top }) => Math.abs(top - firstRowTop) < 1) + .map(({ left }) => left), + ); expect(headerBox).not.toBeNull(); - expect( - Math.abs( - (headerBox?.x ?? 0) + (headerBox?.width ?? 0) - rightmostFirstRowCard, - ), - ).toBeLessThan(1); + expect(Math.abs((headerBox?.x ?? 0) - leftmostFirstRowCard)).toBeLessThan(1); + expect(rightmostFirstRowCard).toBeLessThanOrEqual( + (headerBox?.x ?? 0) + (headerBox?.width ?? 0) + 1, + ); await newAgentCard.click(); await expect( @@ -492,6 +496,63 @@ test("the new team card offers create and import", async ({ page }) => { ).toBeVisible(); }); +test("team cards follow the agents grid alignment at compact widths", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:team-layout", + displayName: "Team layout agent", + systemPrompt: "A test agent for team layout alignment.", + }, + ], + teams: [ + { + id: "team-layout", + name: "Team layout", + personaIds: ["custom:team-layout"], + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + + const agentsContent = page.getByTestId("agents-page-content"); + const firstAgentCard = page.getByTestId( + "persona-agent-row-custom:team-layout", + ); + const firstTeamCard = page.getByTestId("team-card-team-layout"); + const agentGrid = firstAgentCard.locator("xpath=.."); + const teamGrid = firstTeamCard.locator("xpath=.."); + const firstAgentGridCard = agentGrid.locator(":scope > *").first(); + const firstTeamGridCard = teamGrid.locator(":scope > *").first(); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + const wideAgentBox = await firstAgentGridCard.boundingBox(); + const wideTeamBox = await firstTeamGridCard.boundingBox(); + expect(wideAgentBox).not.toBeNull(); + expect(wideTeamBox).not.toBeNull(); + expect(Math.abs((wideAgentBox?.x ?? 0) - (wideTeamBox?.x ?? 0))).toBeLessThan( + 1, + ); + + await agentsContent.evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect + .poll(async () => (await firstAgentGridCard.boundingBox())?.x ?? 0) + .toBeGreaterThan(wideAgentBox?.x ?? 0); + + const compactAgentBox = await firstAgentGridCard.boundingBox(); + const compactTeamBox = await firstTeamGridCard.boundingBox(); + expect( + Math.abs((compactAgentBox?.x ?? 0) - (compactTeamBox?.x ?? 0)), + ).toBeLessThan(1); +}); + test("team cards use the thread-style overlapping avatar stack", async ({ page, }) => { @@ -634,6 +695,62 @@ test("unconfigured agent defaults use the setup label", async ({ page }) => { ); }); +test("moves agent actions into an overflow menu in a narrow view", async ({ + page, +}) => { + await installMockBridge(page, { + personas: [ + { + id: "custom:compact-actions", + displayName: "Compact actions agent", + isActive: true, + systemPrompt: "A test agent for compact header actions.", + }, + ], + managedAgents: [ + { + name: "Compact actions instance", + personaId: "custom:compact-actions", + pubkey: "cd".repeat(32), + status: "running", + }, + ], + }); + await gotoApp(page); + await page.getByTestId("open-agents-view").click(); + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await expect( + page.getByText("Set up and manage your agents.", { exact: true }), + ).toHaveJSProperty("scrollHeight", 24); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "600px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeHidden(); + await page.getByTestId("agent-actions-menu-trigger").click(); + await expect( + page.getByRole("menuitem", { name: "Set agent defaults" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Stop running agents" }), + ).toBeVisible(); + + await page.getByRole("menuitem", { name: "Set agent defaults" }).click(); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toBeVisible(); + + await page.getByTestId("agents-page-content").evaluate((element) => { + (element as HTMLElement).style.width = "650px"; + }); + await expect(page.getByTestId("agent-defaults-button")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("agent-ai-defaults-dialog")).toHaveCount(0); + await expect(page.getByTestId("agent-defaults-button")).toBeFocused(); +}); + test("agent catalog chooser order stays stable when selection changes", async ({ page, }) => { From c9aa55505c544c608ff71648bbfd21b235637f19 Mon Sep 17 00:00:00 2001 From: Matthew Beckley Date: Thu, 30 Jul 2026 11:19:42 -0400 Subject: [PATCH 02/87] desktop: enable getUserMedia in the Linux WebKitGTK webview (#3607) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Microphone/camera capture works on macOS (WKWebView) and Windows (WebView2) but fails on Linux with `NotAllowedError`. WebKitGTK ships with `enable-media-stream` off and a default `permission-request` handler that denies every request. This reaches the underlying `webkit2gtk::WebView` from `on_webview_ready` and enables `enable-media-stream`, then installs a **deny-by-default** `permission-request` handler: a `UserMedia` request is allowed only from a trusted app origin (`tauri://localhost` in prod, the Vite dev origin in debug) **and** when it targets an audio/video device — everything else is denied. No-op on macOS/Windows. - `webkit2gtk` is pinned to the version wry already uses (`=2.0.2`) so there's a single shared copy of the native binding. --------- Signed-off-by: Beckley --- desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/Cargo.toml | 4 + desktop/src-tauri/src/lib.rs | 6 ++ desktop/src-tauri/src/linux_media.rs | 141 +++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) create mode 100644 desktop/src-tauri/src/linux_media.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d1b11b2896..7e23289f53 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "url", "user-idle", "uuid", + "webkit2gtk", "window-vibrancy", "windows-sys 0.61.2", "zeroize", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 90f90870f6..b5b1191852 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -40,6 +40,10 @@ keyring = { version = "3.6.3", default-features = false, features = ["sync-secre # connection is dropped, which the plugin does immediately. Default features # keep the pure-Rust zbus backend, matching the plugin (no libdbus needed). notify-rust = "4" +# Enable getUserMedia in the WebKitGTK webview (see src/linux_media.rs). Pinned +# to the exact version wry links so both resolve to one webkit2gtk-sys and we +# don't get duplicate symbols; bump in lockstep with wry. +webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6.4", default-features = false } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 35ba41bad4..c005d511e6 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -7,6 +7,7 @@ mod deep_link; mod event_sync; mod events; mod huddle; +mod linux_media; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -199,6 +200,11 @@ pub fn run() { return; } + // Linux/WebKitGTK needs media-stream settings and a + // permission-request handler for getUserMedia; no-op + // on macOS/Windows. + linux_media::enable_media_capture(&webview); + // macOS applies the restored geometry asynchronously. Wait // for several identical outer bounds and for React to // commit the startup surface before revealing it. diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs new file mode 100644 index 0000000000..c768e15422 --- /dev/null +++ b/desktop/src-tauri/src/linux_media.rs @@ -0,0 +1,141 @@ +//! Linux-only: enable media capture (`getUserMedia`) in the WebKitGTK webview. +//! +//! On macOS (WKWebView) and Windows (WebView2) the media-permission prompt is +//! routed to the OS automatically, so microphone/camera capture "just works". +//! WebKitGTK is different on two counts, and both must be handled or capture +//! fails on Linux only: +//! +//! * `enable-media-stream` is **off by default**, so `navigator.mediaDevices` +//! never exposes a working `getUserMedia`; and +//! * the default `permission-request` handler **denies every request**, so even +//! with media-stream on, the call rejects with `NotAllowedError`. +//! +//! This module reaches the underlying `webkit2gtk::WebView` via +//! [`tauri::Webview::with_webview`], enables media-stream, and installs a +//! `permission-request` handler that is **deny-by-default**: a `UserMedia` +//! request is allowed only when it comes from a trusted app origin and asks for +//! an audio and/or video device. Tauri does not restrict navigation by default, +//! so without the origin check any document that ended up in this webview would +//! inherit silent mic/camera access for the process lifetime. +//! +//! Buzz's AppImage pins `GDK_BACKEND=x11` (see [`crate::webkit_rendering`]), +//! which is the backend WebKitGTK media capture is reliable on. + +/// The origin Tauri serves the packaged app from on Linux. +const PROD_ORIGIN: &str = "tauri://localhost"; + +/// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` +/// 1420 in `vite.config.ts`). Only trusted in debug builds. +#[cfg(debug_assertions)] +const DEV_ORIGIN: &str = "http://localhost:1420"; + +/// Whether `uri` (the webview's current document URI) is a trusted app origin +/// allowed to use mic/camera. Matches the origin exactly or as a path prefix so +/// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip +/// through. Pure and platform-independent so it can be unit-tested everywhere. +fn is_trusted_media_origin(uri: &str) -> bool { + fn matches(uri: &str, origin: &str) -> bool { + uri == origin + || uri + .strip_prefix(origin) + .is_some_and(|rest| rest.starts_with('/')) + } + + if matches(uri, PROD_ORIGIN) { + return true; + } + #[cfg(debug_assertions)] + if matches(uri, DEV_ORIGIN) { + return true; + } + false +} + +/// Enable microphone/camera capture for `webview` if it is running on +/// WebKitGTK. A no-op on every non-Linux target, so callers can invoke it +/// unconditionally from shared startup code. +#[cfg(target_os = "linux")] +pub fn enable_media_capture(webview: &tauri::Webview) { + use webkit2gtk::{ + glib::prelude::Cast, PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, + UserMediaPermissionRequestExt, WebViewExt, + }; + + // `with_webview` runs the closure on the UI thread, which GTK calls + // require. It errors only if the platform webview is unavailable. + let result = webview.with_webview(|platform_webview| { + // On Linux this is the underlying `webkit2gtk::WebView`. + let webview = platform_webview.inner(); + + if let Some(settings) = WebViewExt::settings(&webview) { + settings.set_enable_media_stream(true); + } + + // Deny-by-default: allow only mic/camera requests from a trusted app + // origin; deny everything else (still returning `true` so WebKit's + // auto-deny default does not also run). Non-`UserMedia` requests return + // `false` and keep their default handling. + webview.connect_permission_request(|wv, request| { + let Some(request) = request.downcast_ref::() else { + return false; + }; + + let uri = wv.uri().map(|u| u.to_string()).unwrap_or_default(); + let for_device = request.is_for_audio_device() || request.is_for_video_device(); + + if for_device && is_trusted_media_origin(&uri) { + request.allow(); + } else { + request.deny(); + } + true + }); + }); + + if let Err(error) = result { + eprintln!("buzz-desktop: could not enable WebKitGTK media capture: {error}"); + } +} + +/// No-op stub so shared startup code can call [`enable_media_capture`] on every +/// platform. macOS and Windows route media permissions through the OS. +#[cfg(not(target_os = "linux"))] +pub fn enable_media_capture(_webview: &tauri::Webview) {} + +#[cfg(test)] +mod tests { + use super::is_trusted_media_origin; + + #[test] + fn allows_production_app_origin() { + assert!(is_trusted_media_origin("tauri://localhost")); + assert!(is_trusted_media_origin( + "tauri://localhost/channels/general" + )); + } + + #[test] + fn denies_untrusted_origins() { + assert!(!is_trusted_media_origin("")); + assert!(!is_trusted_media_origin("https://evil.example.com")); + // Prefix look-alikes must not slip through. + assert!(!is_trusted_media_origin("tauri://localhost.evil.com")); + assert!(!is_trusted_media_origin("tauri://localhostfoo")); + } + + #[cfg(debug_assertions)] + #[test] + fn allows_dev_origin_in_debug_only() { + assert!(is_trusted_media_origin("http://localhost:1420")); + assert!(is_trusted_media_origin("http://localhost:1420/")); + // A different localhost port is still untrusted. + assert!(!is_trusted_media_origin("http://localhost:14200")); + assert!(!is_trusted_media_origin("http://localhost:3000")); + } + + #[cfg(not(debug_assertions))] + #[test] + fn denies_dev_origin_in_release() { + assert!(!is_trusted_media_origin("http://localhost:1420")); + } +} From 9a386a0defbf2b355ee17646c7c11817a535b85f Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 16:23:16 +0100 Subject: [PATCH 03/87] Refine agent sharing dialog (#3699) ## Summary - Refine the agent share dialog around recipient sharing, link copying, catalog sharing, and export. - Show memory settings only when a linked agent has memories to include. - Use a catalog toggle for custom agents and keep built-in agents out of the catalog flow. ## Validation - `pnpm typecheck` - Focused Playwright share and catalog flows --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- .../features/agents/ui/PersonaShareDialog.tsx | 149 +++++------- desktop/tests/e2e/agents.spec.ts | 214 +++++++----------- 2 files changed, 139 insertions(+), 224 deletions(-) diff --git a/desktop/src/features/agents/ui/PersonaShareDialog.tsx b/desktop/src/features/agents/ui/PersonaShareDialog.tsx index c641de9c70..5cf4f9ea3b 100644 --- a/desktop/src/features/agents/ui/PersonaShareDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaShareDialog.tsx @@ -37,10 +37,13 @@ import { Dialog, DialogClose, DialogContent, + DialogDescription, DialogHeader, DialogTitle, } from "@/shared/ui/dialog"; +import { Separator } from "@/shared/ui/separator"; import { Spinner } from "@/shared/ui/spinner"; +import { Switch } from "@/shared/ui/switch"; import { formatShareRecipientName, @@ -63,7 +66,7 @@ type PersonaShareDialogProps = { }; type SnapshotShareDialogProps = { - afterLink?: React.ReactNode; + beforeExport?: React.ReactNode; displayName: string; encodeSnapshot: ( memoryLevel: SnapshotMemoryLevel, @@ -198,7 +201,6 @@ function MemoryShareConfirmation({ function ShareLevelControl({ ariaLabel, disabled, - hasMemoryOptions, testId, value, options, @@ -206,27 +208,11 @@ function ShareLevelControl({ }: { ariaLabel: string; disabled: boolean; - hasMemoryOptions: boolean; testId: string; value: SnapshotMemoryLevel; options: { value: SnapshotMemoryLevel; label: string }[]; onChange: (level: SnapshotMemoryLevel) => void; }) { - if (!hasMemoryOptions) { - // Nothing to choose from, so there is no dropdown to open. State the - // outcome rather than naming the sole option: the memory-level labels - // ("Agent only", "+ core memory", …) are comparative and only make sense - // when the alternatives are actually offered. - return ( - - No memories included - - ); - } - return ( - + Share {displayName} + + Anyone you share this {itemLabel} with will receive a copy they + can add and use. Changes you make later won’t sync. +
-

- They’ll receive a copy they can add and use. Changes you make - later won’t sync. -

+ {hasMemoryOptions ? ( +
+

+ Share settings +

+
+

+ What’s included +

+ +
+
+ ) : null} + + +
- - - -
-

Share with a link

-

- Anyone with the link can add and use a copy. -

-
-
-

- What’s included -

- -
- {showMemoryWarning ? ( ) : null} - - {afterLink}
+ {beforeExport} - + {actions?.length ? ( + + + + + + + + {actions.map((action) => ( + + {action.icon} + {action.label} + + ))} + + + ) : ( + + )}
diff --git a/desktop/src/features/settings/EncryptedBackupProvider.tsx b/desktop/src/features/settings/EncryptedBackupProvider.tsx new file mode 100644 index 0000000000..7375a85cfb --- /dev/null +++ b/desktop/src/features/settings/EncryptedBackupProvider.tsx @@ -0,0 +1,251 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + createNcryptsecBackup, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { + type EncryptedBackupEvent, + type EncryptedBackupState, + encryptedBackupReducer, + initialEncryptedBackupState, + pendingEncryptPassphrase, +} from "./lib/encryptedBackup"; + +const ENCRYPT_DEBOUNCE_MS = 400; +/** How long a completed encrypted backup remains available in memory. */ +export const BACKUP_AVAILABILITY_MS = 5 * 60 * 1000; +const BACKUP_READY_TOAST_ID = "encrypted-key-backup-ready"; + +type EncryptedBackupContextValue = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + backupAvailable: boolean; + availableUntil: number | null; + isSaving: boolean; + saveError: string | null; + downloadBackup: () => Promise; + startNewBackup: () => void; +}; + +const EncryptedBackupContext = + React.createContext(null); + +export function EncryptedBackupProvider({ + children, + onOpenSettings, +}: { + children: React.ReactNode; + onOpenSettings: () => void; +}) { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [availableUntil, setAvailableUntil] = React.useState( + null, + ); + const [isSaving, setIsSaving] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const autoSaveStartedForRef = React.useRef(null); + const mountedRef = React.useRef(true); + const onOpenSettingsRef = React.useRef(onOpenSettings); + + React.useEffect(() => { + onOpenSettingsRef.current = onOpenSettings; + }, [onOpenSettings]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + React.useEffect(() => { + if (availableUntil === null) return; + const expiresIn = Math.max(0, availableUntil - Date.now()); + const timer = window.setTimeout(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + dispatch({ type: "start-new-backup" }); + toast.dismiss(BACKUP_READY_TOAST_ID); + }, expiresIn); + return () => window.clearTimeout(timer); + }, [availableUntil]); + + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let started = false; + let cancelledBeforeStart = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelledBeforeStart) return; + started = true; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => { + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }); + }) + .catch((err: unknown) => { + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }); + }); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + if (!started) cancelledBeforeStart = true; + window.clearTimeout(timer); + }; + }, [pendingPassphrase, skipDebounce, state.nextRequestId]); + + React.useEffect(() => { + if (state.downloadPending) { + toast.loading("Preparing backup…", { + description: "You can close this window while Buzz finishes.", + duration: Number.POSITIVE_INFINITY, + id: BACKUP_READY_TOAST_ID, + }); + return; + } + if ( + state.createError && + state.passphrase.length === 0 && + !state.ncryptsec + ) { + toast.error("Couldn’t create backup", { + description: state.createError, + id: BACKUP_READY_TOAST_ID, + }); + } + }, [ + state.createError, + state.downloadPending, + state.ncryptsec, + state.passphrase.length, + ]); + + const showAvailableToast = React.useCallback( + (description: string, error = false) => { + const options = { + action: { + label: "Open settings", + onClick: () => onOpenSettingsRef.current(), + }, + description, + id: BACKUP_READY_TOAST_ID, + }; + if (error) toast.error("Backup ready to download", options); + else toast.success("Backup ready to download", options); + }, + [], + ); + + const saveBackup = React.useCallback( + async (ncryptsec: string) => { + if (isSaving) return; + setIsSaving(true); + setSaveError(null); + toast("Saving backup…", { + description: "The download window will open when it’s ready.", + id: BACKUP_READY_TOAST_ID, + }); + try { + const path = await saveNcryptsecCopy(ncryptsec); + if (mountedRef.current) { + showAvailableToast( + path === null + ? "Your backup will be available to download for 5 minutes." + : "You can download another copy for 5 minutes.", + ); + } + } catch (err) { + if (!mountedRef.current) return; + const message = + err instanceof Error ? err.message : "Failed to save your key."; + setSaveError(message); + showAvailableToast( + `${message} It will be available to download for 5 minutes.`, + true, + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, + [isSaving, showAvailableToast], + ); + + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || autoSaveStartedForRef.current === ncryptsec) return; + autoSaveStartedForRef.current = ncryptsec; + setAvailableUntil(Date.now() + BACKUP_AVAILABILITY_MS); + void saveBackup(ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const downloadBackup = React.useCallback(async () => { + if (!state.ncryptsec) return; + await saveBackup(state.ncryptsec); + }, [saveBackup, state.ncryptsec]); + + const startNewBackup = React.useCallback(() => { + autoSaveStartedForRef.current = null; + setAvailableUntil(null); + setSaveError(null); + toast.dismiss(BACKUP_READY_TOAST_ID); + dispatch({ type: "start-new-backup" }); + }, []); + + const value = React.useMemo( + () => ({ + state, + dispatch, + backupAvailable: + state.savedPassword && + state.ncryptsec !== null && + availableUntil !== null, + availableUntil, + isSaving, + saveError, + downloadBackup, + startNewBackup, + }), + [ + availableUntil, + downloadBackup, + isSaving, + saveError, + startNewBackup, + state, + ], + ); + + return ( + + {children} + + ); +} + +export function useEncryptedBackup(): EncryptedBackupContextValue { + const value = React.useContext(EncryptedBackupContext); + if (!value) { + throw new Error( + "useEncryptedBackup must be used within EncryptedBackupProvider", + ); + } + return value; +} diff --git a/desktop/src/features/settings/lib/encryptedBackup.test.mjs b/desktop/src/features/settings/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..306e937ec5 --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.test.mjs @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background success retains the password without committing the download", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); + assert.equal(downloadDisabled(state), false); +}); +test("submit commits a completed preload immediately", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("background failure stays silent until submit retries encryption", () => { + const failed = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(failed.passphrase, "one-two-three-four"); + assert.equal(failed.createError, "keychain unavailable"); + assert.equal(pendingEncryptPassphrase(failed), null); + + const retrying = reduce([{ type: "download-clicked" }], failed); + assert.equal(retrying.createError, null); + assert.equal(retrying.downloadPending, true); + assert.equal(pendingEncryptPassphrase(retrying), "one-two-three-four"); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/settings/lib/encryptedBackup.ts b/desktop/src/features/settings/lib/encryptedBackup.ts new file mode 100644 index 0000000000..e54189769a --- /dev/null +++ b/desktop/src/features/settings/lib/encryptedBackup.ts @@ -0,0 +1,143 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + requestId: null, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + } + : { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return state.downloadPending + ? { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + } + : { + ...state, + requestId: null, + createError: event.message, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, createError: null, downloadPending: true }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if ( + state.savedPassword || + state.encrypted || + state.requestId !== null || + (state.createError !== null && !state.downloadPending) + ) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/settings/ui/BackupTestFlow.tsx b/desktop/src/features/settings/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..65ef40a98a --- /dev/null +++ b/desktop/src/features/settings/ui/BackupTestFlow.tsx @@ -0,0 +1,459 @@ +import { Check, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Progress through the Settings backup-test flow. The password attempt is + * deliberately NOT part of this state — it lives only in short-lived + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + progress, + onProgressChange, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError("That doesn't look like a key backup file."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange]); + + if (stage === "success" && result) { + return ( +
+ {reduceMotion ? null : } + + + +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+
+ +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + <> + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + + ) : ( + <> +
+
+

+ That's the one. Now enter your password to prove you can unlock it. +

+
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+ + )} +
+ ); +} diff --git a/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..fb20eb9c65 --- /dev/null +++ b/desktop/src/features/settings/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,375 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; + +import { generateBackupPassphrase } from "@/shared/api/tauriIdentity"; +import { useEncryptedBackup } from "@/features/settings/EncryptedBackupProvider"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { downloadDisabled, MIN_PASSPHRASE_LEN } from "../lib/encryptedBackup"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Indeterminate KDF progress. Scrypt does not expose intermediate progress, + * so randomized increments consume a shrinking fraction of the remaining + * distance. The bar moves quickly at first and can never reach completion. + */ +function FakeKdfProgressBar() { + const [progress, setProgress] = React.useState(0); + + React.useEffect(() => { + let animationFrame = 0; + let nextAdvanceAt = 0; + const advance = (now: number) => { + if (now >= nextAdvanceAt) { + setProgress((current) => { + const remaining = 90 - current; + const fraction = 0.08 + Math.random() * 0.22; + return Math.min(90, current + Math.max(0.25, remaining * fraction)); + }); + nextAdvanceAt = now + 180 + Math.random() * 420; + } + animationFrame = window.requestAnimationFrame(advance); + }; + animationFrame = window.requestAnimationFrame(advance); + return () => window.cancelAnimationFrame(animationFrame); + }, []); + + return ( +
+
+
+ ); +} + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow for Settings. The raw private + * key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly; clicking mid-encryption queues the + * download until the KDF finishes. + */ +export function EncryptedBackupCreator({ + onOpenChange, + open, +}: { + onOpenChange: (open: boolean) => void; + open: boolean; +}) { + const { state, dispatch, isSaving, saveError } = useEncryptedBackup(); + const [isRevealed, setIsRevealed] = React.useState(false); + + // A queued download hides the form; mask the password before it can return + // in any error state. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + React.useEffect(() => { + if (state.ncryptsec) onOpenChange(false); + }, [onOpenChange, state.ncryptsec]); + + return ( + + + + Create a key backup + + You can close this window while Buzz finishes the backup in the + background. + + +
+ {state.downloadPending ? ( + + ) : !state.savedPassword ? ( +
+ + dispatch({ + type: "set-passphrase", + value: event.target.value, + }) + } + placeholder={`Password (min ${MIN_PASSPHRASE_LEN} characters)`} + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + + { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + /> +
+ ) : null} + + {!state.downloadPending && !state.savedPassword ? ( +

+ Keep the file private and save its password somewhere safe — Buzz + cannot reset it. Once ready, the backup remains available to + download for 5 minutes. +

+ ) : null} + + {state.createError && state.passphrase.length === 0 ? ( +

+ {state.createError} +

+ ) : null} + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + {!state.downloadPending ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx new file mode 100644 index 0000000000..8eb0256a55 --- /dev/null +++ b/desktop/src/features/settings/ui/PrivateKeyBackupRow.tsx @@ -0,0 +1,215 @@ +import { Download, Eye, EyeOff, ShieldCheck } from "lucide-react"; +import * as React from "react"; + +import { NsecMaskedDisplay } from "@/features/onboarding/ui/NsecMaskedDisplay"; +import { + BACKUP_AVAILABILITY_MS, + useEncryptedBackup, +} from "@/features/settings/EncryptedBackupProvider"; +import { + BackupTestFlow, + initialBackupTestProgress, +} from "@/features/settings/ui/BackupTestFlow"; +import { EncryptedBackupCreator } from "@/features/settings/ui/EncryptedBackupCreator"; +import { getNsec } from "@/shared/api/tauriIdentity"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; + +function BackupAvailabilityFill({ + availableUntil, +}: { + availableUntil: number; +}) { + const [{ durationMs, initialWidth }] = React.useState(() => { + const remainingMs = Math.max(0, availableUntil - Date.now()); + return { + durationMs: remainingMs, + initialWidth: Math.min(100, (remainingMs / BACKUP_AVAILABILITY_MS) * 100), + }; + }); + const [width, setWidth] = React.useState(initialWidth); + + React.useEffect(() => { + const frame = window.requestAnimationFrame(() => setWidth(0)); + return () => window.cancelAnimationFrame(frame); + }, []); + + return ( +
diff --git a/desktop/src/shared/api/tauriIdentity.ts b/desktop/src/shared/api/tauriIdentity.ts index e6056a4a58..e6ec266bff 100644 --- a/desktop/src/shared/api/tauriIdentity.ts +++ b/desktop/src/shared/api/tauriIdentity.ts @@ -49,3 +49,52 @@ export async function persistCurrentIdentity(): Promise { export async function signOut(): Promise { await invokeTauri("sign_out"); } + +export type GeneratePassphraseOptions = { + /** Word count; Rust clamps to its allowed range (currently 3–10). */ + words?: number; + /** Separator joined between words. Defaults to a space in Rust. */ + separator?: string; +}; + +/** Generate a word passphrase (EFF short wordlist, OS entropy) in Rust. */ +export async function generateBackupPassphrase( + options?: GeneratePassphraseOptions, +): Promise { + return invokeTauri("generate_backup_passphrase", { + words: options?.words, + separator: options?.separator, + }); +} + +/** Encrypt the current identity as an in-memory NIP-49 backup for native save. */ +export async function createNcryptsecBackup(password: string): Promise { + return invokeTauri("create_ncryptsec_backup", { password }); +} + +/** Save a portable backup copy. Returns null when the native dialog is cancelled. */ +export async function saveNcryptsecCopy( + ncryptsec: string, +): Promise { + return ( + (await invokeTauri("save_ncryptsec_copy", { ncryptsec })) ?? + null + ); +} + +export type BackupVerification = { + pubkey: string; + npub: string; + matchesCurrentIdentity: boolean; +}; + +/** Decrypt locally and return only the backup's public identity and match state. */ +export async function verifyNcryptsecBackup( + ncryptsec: string, + password: string, +): Promise { + return invokeTauri("verify_ncryptsec_backup", { + ncryptsec, + password, + }); +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index d15f2269d3..4b19004965 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,6 +1,6 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; -import { decode } from "nostr-tools/nip19"; +import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import { @@ -417,6 +417,14 @@ type E2eConfig = { * autosave behaviour while a request is in flight. 0/undefined = instant. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message instead of returning the * mock nsec string. Use `nsecErrors` for sequenced failure/success. @@ -7204,6 +7212,8 @@ let mockGlobalAgentConfig: { // Per-page get_nsec call counter for sequenced error testing. let nsecCallCount = 0; +let backupVerificationCallCount = 0; +let backupSaveCallCount = 0; // Per-page explicit catalog publication outcomes. let personaSharePublicationCallCount = 0; @@ -9843,6 +9853,43 @@ export function maybeInstallE2eTauriMocks() { // harness there is nothing to wipe; resolving is enough — specs // assert invocation via __BUZZ_E2E_COMMANDS__ and the pending UI. return; + case "generate_backup_passphrase": + return "correct horse battery staple"; + case "create_ncryptsec_backup": { + const delayMs = activeConfig?.mock?.backupEncryptionDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + return "ncryptsec1mockbackupmaterial"; + } + case "save_ncryptsec_copy": { + const paths = activeConfig?.mock?.backupSavePaths ?? [ + "/tmp/buzz-identity.ncryptsec", + ]; + const index = Math.min(backupSaveCallCount, paths.length - 1); + backupSaveCallCount += 1; + return paths[index]; + } + case "verify_ncryptsec_backup": { + const errors = activeConfig?.mock?.backupVerificationErrors ?? [null]; + const index = Math.min(backupVerificationCallCount, errors.length - 1); + const error = errors[index]; + if (error) { + backupVerificationCallCount += 1; + throw new Error(error); + } + const pubkeys = activeConfig?.mock?.backupVerificationPubkeys ?? [ + identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey, + ]; + const pubkey = pubkeys[Math.min(index, pubkeys.length - 1)]; + backupVerificationCallCount += 1; + return { + pubkey, + npub: npubEncode(pubkey), + matchesCurrentIdentity: + pubkey === (identity?.pubkey ?? DEFAULT_MOCK_IDENTITY.pubkey), + }; + } case "get_nsec": { const nsecSequence = activeConfig?.mock?.nsecErrors; if (nsecSequence && nsecSequence.length > 0) { diff --git a/desktop/tests/e2e/profile-backup-settings.spec.ts b/desktop/tests/e2e/profile-backup-settings.spec.ts new file mode 100644 index 0000000000..edd66170da --- /dev/null +++ b/desktop/tests/e2e/profile-backup-settings.spec.ts @@ -0,0 +1,259 @@ +import { expect, test, type Page } from "@playwright/test"; +import { npubEncode } from "nostr-tools/nip19"; + +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const CURRENT_PUBKEY = "deadbeef".repeat(8); +const DIFFERENT_PUBKEY = "c0ffee00".repeat(8); +const BACKUP_FILE = { + name: "identity.ncryptsec", + mimeType: "text/plain", + buffer: Buffer.from("ncryptsec1mockbackupmaterial"), +}; + +async function openIdentity(page: Page) { + const identity = page.getByTestId("profile-identity-card"); + if ( + !(await identity.evaluate( + (element) => element instanceof HTMLDetailsElement && element.open, + )) + ) { + await page.getByTestId("profile-identity-toggle").click(); + } +} + +async function openBackupSettings( + page: Page, + mock?: Parameters[1], +) { + await installMockBridge(page, mock); + await page.goto("/"); + await openSettings(page, "profile"); + await openIdentity(page); +} + +async function openPrivateKeyMenu(page: Page) { + const reveal = page.getByTestId("profile-private-key-toggle"); + if ((await reveal.textContent())?.trim() === "Reveal") { + await reveal.click(); + } + await page.getByTestId("nsec-actions").click(); + await expect(page.getByTestId("private-key-create-backup")).toBeVisible(); +} + +async function openCreateBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-create-backup").click(); + const dialog = page.getByTestId("encrypted-backup-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function openTestBackup(page: Page) { + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const dialog = page.getByTestId("backup-test-dialog"); + await expect(dialog).toBeVisible(); + return dialog; +} + +async function selectBackupFile(page: Page) { + await page.getByTestId("backup-test-file-input").setInputFiles(BACKUP_FILE); + await expect(page.getByTestId("backup-test-file-accepted")).toContainText( + BACKUP_FILE.name, + ); +} + +async function verifyBackup(page: Page, password: string) { + await page.getByTestId("backup-test-password").fill(password); + await page.getByTestId("backup-test-verify").click(); +} + +async function backupSaveCallCount(page: Page) { + return page.evaluate( + () => + window.__BUZZ_E2E_COMMANDS__?.filter( + (command) => command === "save_ncryptsec_copy", + ).length ?? 0, + ); +} + +test("private key menu replaces the backup settings rows", async ({ page }) => { + await openBackupSettings(page); + + await expect(page.getByTestId("profile-encrypted-backup-row")).toHaveCount(0); + await expect(page.getByTestId("profile-backup-test-row")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await expect(page.getByTestId("nsec-copy")).toContainText("Copy"); + await expect(page.getByTestId("private-key-create-backup")).toHaveText( + "Create backup", + ); + await expect(page.getByTestId("private-key-test-backup")).toHaveText( + "Test backup", + ); + + await page.getByTestId("nsec-copy").click(); + await expect(page.getByText(/clipboard$/i)).toBeVisible(); + await expect(page.getByTestId("private-key-create-backup")).toHaveCount(0); + + await openPrivateKeyMenu(page); + await page.getByTestId("private-key-test-backup").click(); + const testDialog = page.getByTestId("backup-test-dialog"); + await expect(testDialog).toContainText("Test a key backup"); + await expect(testDialog.getByText("Select your backup file")).toBeVisible(); + await expect(testDialog).toContainText("standard NIP-49 format"); +}); + +test("creation requires a sufficiently long password and exposes a temporary header download", async ({ + page, +}) => { + await openBackupSettings(page, { + backupSavePaths: [ + "/Users/test/Downloads/identity.ncryptsec", + "/Users/test/Desktop/identity-copy.ncryptsec", + ], + }); + const dialog = await openCreateBackup(page); + + const password = dialog.getByTestId("backup-passphrase-input"); + const submit = dialog.getByTestId("encrypted-backup-create"); + await expect(password).toHaveAttribute( + "placeholder", + "Password (min 12 characters)", + ); + await expect(submit).toBeDisabled(); + await password.fill("short"); + await expect(submit).toBeDisabled(); + + await password.fill("custom password"); + await expect(submit).toBeEnabled(); + await submit.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + await expect(dialog).toBeHidden(); + + const keyRow = page.getByTestId("profile-private-key-row"); + const download = keyRow.getByTestId("encrypted-backup-download"); + await expect(download).toBeVisible(); + await expect(download).toHaveText("Download backup"); + await expect(download).toHaveClass(/bg-primary/); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await expect(keyRow.getByTestId("profile-private-key-toggle")).toBeVisible(); + + await download.click(); + await expect.poll(() => backupSaveCallCount(page)).toBe(2); +}); + +test("encryption and native save continue after closing the dialog and settings", async ({ + page, +}) => { + await openBackupSettings(page, { + backupEncryptionDelayMs: 750, + backupSavePaths: [null], + }); + const dialog = await openCreateBackup(page); + await dialog + .getByTestId("backup-passphrase-input") + .fill("background password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await expect(dialog.getByTestId("encrypted-backup-progress")).toBeVisible(); + + await dialog.getByRole("button", { name: "Close" }).click(); + await page.getByTestId("settings-back-to-app").click(); + await expect(page.getByTestId("settings-back-to-app")).toHaveCount(0); + await expect( + page.getByText("Preparing backup…", { exact: true }), + ).toBeVisible(); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const readyToast = page.getByText("Backup ready to download", { + exact: true, + }); + await expect(readyToast).toBeVisible(); + await expect( + page.getByText("Your backup will be available to download for 5 minutes.", { + exact: true, + }), + ).toBeVisible(); + + await page.getByRole("button", { name: "Open settings" }).click(); + await expect(page.getByTestId("settings-back-to-app")).toBeVisible(); + await openIdentity(page); + await expect(page.getByTestId("encrypted-backup-download")).toBeVisible(); +}); + +test("the temporary download expires after five minutes", async ({ page }) => { + await page.clock.install({ time: new Date("2026-07-29T12:00:00Z") }); + await openBackupSettings(page); + const dialog = await openCreateBackup(page); + await dialog.getByTestId("backup-passphrase-input").fill("expiring password"); + await dialog.getByTestId("encrypted-backup-create").click(); + await page.clock.fastForward(1); + + await expect.poll(() => backupSaveCallCount(page)).toBe(1); + const download = page.getByTestId("encrypted-backup-download"); + await expect(download).toHaveText("Download backup"); + await expect( + download.getByTestId("encrypted-backup-availability-fill"), + ).toBeVisible(); + await page.clock.fastForward(5 * 60 * 1000 + 1); + await expect(page.getByTestId("encrypted-backup-download")).toHaveCount(0); +}); + +test("wrong backup password permits a successful retry in the test modal", async ({ + page, +}) => { + await openBackupSettings(page, { + backupVerificationErrors: ["Wrong password.", null], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + + await verifyBackup(page, "wrong password"); + await expect(dialog.getByTestId("backup-test-error")).toHaveText( + "Wrong password.", + ); + await expect(dialog.getByTestId("backup-test-password")).toHaveValue(""); + await expect(dialog.getByTestId("backup-test-verify")).toBeDisabled(); + + await verifyBackup(page, "correct password"); + await expect(dialog.getByTestId("backup-test-success")).toContainText( + "It restores your current Buzz identity.", + ); +}); + +for (const identity of [ + { + label: "current", + pubkey: CURRENT_PUBKEY, + message: "It restores your current Buzz identity.", + }, + { + label: "different", + pubkey: DIFFERENT_PUBKEY, + message: "It restores a different identity than the one signed in here.", + }, +]) { + test(`successful modal verification identifies the ${identity.label} identity using only its npub`, async ({ + page, + }) => { + await openBackupSettings(page, { + backupVerificationPubkeys: [identity.pubkey], + }); + const dialog = await openTestBackup(page); + await selectBackupFile(page); + await verifyBackup(page, "one-time password"); + + const success = dialog.getByTestId("backup-test-success"); + await expect(success).toContainText(identity.message); + await expect(success.getByTestId("backup-test-npub")).toContainText( + npubEncode(identity.pubkey), + ); + await expect(success).not.toContainText(identity.pubkey); + await expect(success).not.toContainText("one-time password"); + await expect(success).not.toContainText(BACKUP_FILE.buffer.toString()); + }); +} diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index b48a75914b..468f860203 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -441,6 +441,14 @@ type MockBridgeOptions = { /** Delay (ms) for `set_global_agent_config` — hold saves open in tests. * Alias of `globalConfigSaveDelayMs` (kept for onboarding specs). */ setGlobalAgentConfigDelayMs?: number; + /** Errors returned by successive backup verification attempts. Null succeeds. */ + backupVerificationErrors?: (string | null)[]; + /** Public identities returned by successive successful backup verifications. */ + backupVerificationPubkeys?: string[]; + /** Delay (ms) applied to backup encryption so specs can observe pending UI. */ + backupEncryptionDelayMs?: number; + /** Native paths returned by successive backup saves. */ + backupSavePaths?: Array; /** * When set, `get_nsec` throws with this message. For a single always-fail * scenario. Use `nsecErrors` for sequenced fail/succeed. From cca8839034eb571a7ce943c3ace7f85a82330898 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:25:08 -0600 Subject: [PATCH 12/87] Make relay reconnect backoff authoritative (#3774) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - make the relay reconnect coordinator authoritative during outages so query, publish, and subscription traffic waits for the scheduled attempt instead of cancelling backoff - release waiting operations after the coordinated AUTH + live-subscription replay attempt, while preserving one explicit manual reconnect fast path - suppress duplicate notification side effects when reconnect replay overlaps previously delivered events ## Root cause `resetConnection()` scheduled exponential backoff, but `ensureConnected()` cleared any pending reconnect timer. Operation-level retry paths immediately called `ensureConnected()`, so ordinary app traffic could repeatedly bypass the reconnect policy during an outage. The resulting churn also replayed overlapping live events into notification side effects without a shared event-ID guard. ## Validation - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,823 passed - pre-push: `desktop-check`, `desktop-test`, and `branch-skew` passed - file-size, px-text, and pubkey-truncation ratchets passed --------- Signed-off-by: Wes Co-authored-by: Carl --- .../channels/unreadReadMarker.test.mjs | 11 ++ .../channels/useLiveChannelUpdates.ts | 98 ++++++----- desktop/src/shared/api/relayClientSession.ts | 94 +++++------ .../shared/api/relayReconnectPolicy.test.mjs | 12 ++ .../src/shared/api/relayReconnectPolicy.ts | 6 + .../shared/api/relayReconnectWaiters.test.mjs | 26 +++ .../src/shared/api/relayReconnectWaiters.ts | 21 +++ desktop/src/testing/e2eBridge.ts | 24 +++ desktop/tests/e2e/helpers/twoRelayHarness.ts | 1 + desktop/tests/e2e/relay-reconnect.spec.ts | 74 ++++++++ desktop/tests/e2e/relay-restart.live.spec.ts | 158 +++++++++++++++++- 11 files changed, 429 insertions(+), 96 deletions(-) create mode 100644 desktop/src/shared/api/relayReconnectWaiters.test.mjs create mode 100644 desktop/src/shared/api/relayReconnectWaiters.ts diff --git a/desktop/src/features/channels/unreadReadMarker.test.mjs b/desktop/src/features/channels/unreadReadMarker.test.mjs index e05e2ba0b2..6ea0916413 100644 --- a/desktop/src/features/channels/unreadReadMarker.test.mjs +++ b/desktop/src/features/channels/unreadReadMarker.test.mjs @@ -18,6 +18,7 @@ import { } from "./useUnreadChannels.ts"; import { isChannelUnreadTriggerKind, + trackSeenEvent, withChannelTagFallback, } from "./useLiveChannelUpdates.ts"; import { @@ -90,6 +91,16 @@ test("live event with h tag is preserved", () => { assert.equal(withChannelTagFallback(message, "other-channel"), message); }); +test("notification event guard suppresses reconnect replay and stays bounded", () => { + const seen = new Set(); + + assert.equal(trackSeenEvent(seen, "event-a", 2), true); + assert.equal(trackSeenEvent(seen, "event-a", 2), false); + assert.equal(trackSeenEvent(seen, "event-b", 2), true); + assert.equal(trackSeenEvent(seen, "event-c", 2), true); + assert.deepEqual([...seen], ["event-b", "event-c"]); +}); + test("dmHuddleStart_isDmOnlyUnreadTrigger", () => { assert.equal( isChannelUnreadTriggerKind(KIND_HUDDLE_STARTED, true), diff --git a/desktop/src/features/channels/useLiveChannelUpdates.ts b/desktop/src/features/channels/useLiveChannelUpdates.ts index aeb3abb905..800467b6ea 100644 --- a/desktop/src/features/channels/useLiveChannelUpdates.ts +++ b/desktop/src/features/channels/useLiveChannelUpdates.ts @@ -110,13 +110,19 @@ function isExternalMentionEvent(event: RelayEvent, currentPubkey: string) { ); } -function trackSeenEvent(seenEventIds: Set, eventId: string): boolean { +const SEEN_NOTIFICATION_EVENT_LIMIT = 5_000; + +export function trackSeenEvent( + seenEventIds: Set, + eventId: string, + limit = 200, +): boolean { if (seenEventIds.has(eventId)) { return false; } seenEventIds.add(eventId); - if (seenEventIds.size > 200) { + if (seenEventIds.size > limit) { const oldestEventId = seenEventIds.values().next().value; if (oldestEventId) { seenEventIds.delete(oldestEventId); @@ -135,6 +141,11 @@ export function useLiveChannelUpdates( const normalizedCurrentPubkey = options.currentPubkey?.trim().toLowerCase() ?? ""; const seenMentionEventIdsRef = React.useRef(new Set()); + // Reconnect replay overlaps each live filter by five seconds so no message is + // lost at the boundary. Keep one shared guard for every notification side + // effect: the same event can be replayed repeatedly while a relay flaps, and + // mention events also arrive through both the channel and mention filters. + const seenNotificationEventIdsRef = React.useRef(new Set()); const channelsInvalidateRef = React.useRef(null); if (channelsInvalidateRef.current === null) { channelsInvalidateRef.current = createTrailingDebounce(() => { @@ -164,7 +175,6 @@ export function useLiveChannelUpdates( ), [channels], ); - const seenDmEventIdsRef = React.useRef(new Set()); const dmSubscriptionStartedAtRef = React.useRef(0); // Reset subscription timestamp when identity changes. @@ -181,44 +191,42 @@ export function useLiveChannelUpdates( [channels], ); - const handleDmEvent = React.useEffectEvent((event: RelayEvent) => { - // Only human-visible message kinds should fire DM notifications. - if (!isDmNotifiableKind(event.kind)) { - return; - } - - // Suppress backlog events that predate our subscription — these are - // historical replays, not live messages. - if (event.created_at < dmSubscriptionStartedAtRef.current) { - return; - } + const handleDmEvent = React.useEffectEvent( + (event: RelayEvent, isFirstNotificationDelivery: boolean) => { + // Only human-visible message kinds should fire DM notifications. + if (!isDmNotifiableKind(event.kind) || !isFirstNotificationDelivery) { + return; + } - const channelId = getChannelIdFromTags(event.tags); - if (!channelId) { - return; - } + // Suppress backlog events that predate our subscription — these are + // historical replays, not live messages. + if (event.created_at < dmSubscriptionStartedAtRef.current) { + return; + } - if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { - return; - } + const channelId = getChannelIdFromTags(event.tags); + if (!channelId) { + return; + } - const dmChannel = dmChannelMap.get(channelId); - if (!dmChannel) { - return; - } + if (!isExternalMentionEvent(event, normalizedCurrentPubkey)) { + return; + } - if (!trackSeenEvent(seenDmEventIdsRef.current, event.id)) { - return; - } + const dmChannel = dmChannelMap.get(channelId); + if (!dmChannel) { + return; + } - // Don't fire a notification for the channel the user is already viewing, - // unless the notify-while-viewing setting opts in. - if (channelId === activeChannelId && !options.notifyForActiveChannel) { - return; - } + // Don't fire a notification for the channel the user is already viewing, + // unless the notify-while-viewing setting opts in. + if (channelId === activeChannelId && !options.notifyForActiveChannel) { + return; + } - options.onDmMessage?.(event, dmChannel); - }); + options.onDmMessage?.(event, dmChannel); + }, + ); const handleIncomingMessage = React.useEffectEvent((event: RelayEvent) => { const channelId = getChannelIdFromTags(event.tags); @@ -226,12 +234,6 @@ export function useLiveChannelUpdates( return; } - // Track DM events even for the active channel so the dedup set stays - // current. The handler itself skips firing the notification callback - // when the user is already viewing the DM (unless opted in via - // notifyForActiveChannel). - handleDmEvent(event); - if (!liveChannelIds.has(channelId)) { if (channelId !== activeChannelId) { invalidateChannelsDebounced(); @@ -263,9 +265,21 @@ export function useLiveChannelUpdates( isUnreadTriggerKind && (normalizedCurrentPubkey.length === 0 || event.pubkey.toLowerCase() !== normalizedCurrentPubkey); + const isFirstNotificationDelivery = + !isExternalTriggerEvent || + trackSeenEvent( + seenNotificationEventIdsRef.current, + event.id, + SEEN_NOTIFICATION_EVENT_LIMIT, + ); const isThreadedReply = isThreadReply(event.tags); - if (isExternalTriggerEvent) { + // DM alerts and every other notification side effect share this delivery + // decision, preventing a replayed event from escaping through a second + // callback path. + handleDmEvent(event, isFirstNotificationDelivery); + + if (isExternalTriggerEvent && isFirstNotificationDelivery) { const shouldNotify = shouldNotifyForEvent( event, normalizedCurrentPubkey, diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 84ee10b68d..8274034ed5 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -49,7 +49,9 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "@/shared/api/relayReconnectPolicy"; +import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; @@ -57,26 +59,12 @@ const RECONNECT_BASE_DELAY_MS = 1_000, RECONNECT_MAX_DELAY_MS = 30_000, EVENT_BATCH_MS = 16; -/** - * Op-level timeout constants. Raised from 8 s to 25 s to survive degraded - * networks where TLS handshakes and DNS resolution can take 3–10 s. - */ export const AUTH_TIMEOUT_MS = 25_000; export const HISTORY_TIMEOUT_MS = 25_000; export const PUBLISH_TIMEOUT_MS = 25_000; -/** - * The connection must remain stable for this long after a successful AUTH - * before the reconnect backoff delay resets to its base value. Stability- - * gated reset prevents repeated fast reconnects (flapping) from erasing the - * backoff that throttles them. - */ export const BACKOFF_RESET_STABLE_MS = 60_000; -/** - * Passive liveness check. The relay sends heartbeat pings every 30s; if no - * inbound frame arrives for two heartbeat windows, treat the socket as stalled. - */ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_IDLE_TIMEOUT_MS = 60_000; @@ -85,6 +73,7 @@ export class RelayClient { private relayUrl: string | null = null; private connectPromise: Promise | null = null; private reconnectTimeout: number | null = null; + private reconnectWaiters = new RelayReconnectWaiters(); private reconnectDelayMs = RECONNECT_BASE_DELAY_MS; private keepAliveRequested = false; private authRequest: { @@ -105,16 +94,6 @@ export class RelayClient { private stabilityTimer: number | null = null; private visibleChannelId: string | null = null; - /** - * Sticky terminal flag. Set when `resetConnection` is called with - * `reconnect: false` (today: auth rejection). Acts as a hard guard against - * the reconnect-timer / retry-wrapper paths racing back to "reconnecting" - * after we've already declared the session dead. - * - * Cleared only on explicit user re-engagement: `disconnect()` (community - * switch — the singleton is being reused for a different community) and - * `preconnect()` (caller is asking us to come back up). - */ private terminal = false; private connectionStateEmitter = new RelayConnectionStateEmitter("idle"); @@ -127,21 +106,10 @@ export class RelayClient { }, }); - /** - * Track which channel the user is currently viewing so its subscriptions - * are sent first during reconnect replay — reducing visible latency on - * degraded networks where the relay REQ storm would otherwise delay all - * channels equally. - */ setVisibleChannelId(id: string | null) { this.visibleChannelId = id; } - /** - * Cleanly tear down the connection without scheduling a reconnect. - * Used during community switches to reset the singleton before the - * new community applies. - */ disconnect() { const error = new Error("Relay disconnected for community switch."); @@ -169,6 +137,7 @@ export class RelayClient { } this.connectPromise = null; + this.reconnectWaiters.settle(error); if (this.authRequest) { window.clearTimeout(this.authRequest.timeout); @@ -247,10 +216,6 @@ export class RelayClient { return this.fetchHistory(filter); } - /** - * Return the first event matching `filter` as soon as it arrives, without - * waiting for EOSE. Resolves to `null` when EOSE arrives before any event. - */ async fetchFirstEvent( filter: RelaySubscriptionFilter, ): Promise { @@ -462,10 +427,24 @@ export class RelayClient { async preconnect() { // Explicit re-engagement. If the session went terminal (auth rejection) - // the caller is asking us to try again, so clear the latch. + // the caller is asking us to try again, so clear the latch. A manual + // reconnect also bypasses the current delay once; ordinary operations do + // not, so background traffic cannot continuously defeat backoff. this.terminal = false; this.keepAliveRequested = true; - await this.ensureConnected(); + if (this.reconnectTimeout !== null) { + window.clearTimeout(this.reconnectTimeout); + this.reconnectTimeout = null; + } + try { + await this.ensureConnected(); + this.reconnectWaiters.settle(); + } catch (error) { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + throw error; + } } subscribeToReconnects(listener: () => void) { @@ -508,9 +487,15 @@ export class RelayClient { return; } - if (this.reconnectTimeout) { - window.clearTimeout(this.reconnectTimeout); - this.reconnectTimeout = null; + if ( + shouldWaitForScheduledReconnect({ + hasPendingReconnect: this.reconnectTimeout !== null, + }) + ) { + // The reconnect coordinator owns outage pacing. Query, publish, and + // subscription callers must wait for its scheduled attempt instead of + // clearing the timer and creating an immediate reconnect storm. + return this.waitForScheduledReconnect(); } const connectPromise = this.connect(); @@ -964,6 +949,13 @@ export class RelayClient { } } + private waitForScheduledReconnect(): Promise { + if (this.reconnectTimeout === null) { + return this.ensureConnected(); + } + return this.reconnectWaiters.wait(); + } + private scheduleReconnect() { if ( !shouldScheduleReconnect({ @@ -989,9 +981,14 @@ export class RelayClient { this.reconnectTimeout = window.setTimeout(() => { this.reconnectTimeout = null; - void this.ensureConnected().catch(() => { - this.scheduleReconnect(); - }); + void this.ensureConnected() + .then(() => this.reconnectWaiters.settle()) + .catch((error) => { + this.reconnectWaiters.settle( + this.normalizeRelayError(error, "Relay reconnect failed."), + ); + this.scheduleReconnect(); + }); }, delay); } @@ -1049,6 +1046,9 @@ export class RelayClient { window.clearTimeout(this.reconnectTimeout); this.reconnectTimeout = null; } + if (options?.reconnect === false) { + this.reconnectWaiters.settle(error); + } if (this.wsId !== null) { void closeWebSocket(this.wsId, "connection reset"); diff --git a/desktop/src/shared/api/relayReconnectPolicy.test.mjs b/desktop/src/shared/api/relayReconnectPolicy.test.mjs index 6f375fb436..e6856ede18 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.test.mjs +++ b/desktop/src/shared/api/relayReconnectPolicy.test.mjs @@ -6,6 +6,7 @@ import { isWebSocketClose, shouldRefuseConnect, shouldScheduleReconnect, + shouldWaitForScheduledReconnect, } from "./relayReconnectPolicy.ts"; // The "happy" baseline that *should* schedule a reconnect: not terminal, @@ -79,6 +80,17 @@ test("keep-alive alone is enough to schedule", () => { ); }); +test("ordinary operations wait for a scheduled reconnect instead of bypassing backoff", () => { + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: true }), + true, + ); + assert.equal( + shouldWaitForScheduledReconnect({ hasPendingReconnect: false }), + false, + ); +}); + test("shouldRefuseConnect mirrors terminal", () => { assert.equal(shouldRefuseConnect({ terminal: false }), false); assert.equal(shouldRefuseConnect({ terminal: true }), true); diff --git a/desktop/src/shared/api/relayReconnectPolicy.ts b/desktop/src/shared/api/relayReconnectPolicy.ts index a2cf358216..00d8e412bd 100644 --- a/desktop/src/shared/api/relayReconnectPolicy.ts +++ b/desktop/src/shared/api/relayReconnectPolicy.ts @@ -37,6 +37,12 @@ export function shouldScheduleReconnect(inputs: RelayReconnectInputs): boolean { return true; } +export function shouldWaitForScheduledReconnect(inputs: { + hasPendingReconnect: boolean; +}): boolean { + return inputs.hasPendingReconnect; +} + /** Whether `ensureConnected()` should refuse with a terminal error. */ export function shouldRefuseConnect(inputs: { terminal: boolean }): boolean { return inputs.terminal; diff --git a/desktop/src/shared/api/relayReconnectWaiters.test.mjs b/desktop/src/shared/api/relayReconnectWaiters.test.mjs new file mode 100644 index 0000000000..ddf3244526 --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { RelayReconnectWaiters } from "./relayReconnectWaiters.ts"; + +test("settle releases every operation waiting on a successful reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + + waiters.settle(); + + await Promise.all([first, second]); +}); + +test("settle rejects every operation after a failed reconnect", async () => { + const waiters = new RelayReconnectWaiters(); + const first = waiters.wait(); + const second = waiters.wait(); + const error = new Error("relay unavailable"); + + waiters.settle(error); + + await assert.rejects(first, error); + await assert.rejects(second, error); +}); diff --git a/desktop/src/shared/api/relayReconnectWaiters.ts b/desktop/src/shared/api/relayReconnectWaiters.ts new file mode 100644 index 0000000000..06ad1daa3f --- /dev/null +++ b/desktop/src/shared/api/relayReconnectWaiters.ts @@ -0,0 +1,21 @@ +export class RelayReconnectWaiters { + private waiters = new Set<{ + resolve: () => void; + reject: (error: Error) => void; + }>(); + + wait(): Promise { + return new Promise((resolve, reject) => { + this.waiters.add({ resolve, reject }); + }); + } + + settle(error?: Error) { + const waiters = [...this.waiters]; + this.waiters.clear(); + for (const waiter of waiters) { + if (error) waiter.reject(error); + else waiter.resolve(); + } + } +} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 4b19004965..07eaa77902 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1108,6 +1108,11 @@ declare global { __BUZZ_E2E_SET_STALL_WEBSOCKET_SENDS__?: (stall: boolean) => void; __BUZZ_E2E_DISCONNECT_MOCK_WEBSOCKETS__?: () => number; __BUZZ_E2E_RESTART_MOCK_WEBSOCKETS__?: () => number; + __BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__?: ( + unavailable: boolean, + ) => void; + __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; models?: Array<{ id: string; name: string | null }>; @@ -2820,6 +2825,8 @@ const mockReminderEvents: RelayEvent[] = []; const mockPersonaEvents: RelayEvent[] = []; let mockRelayMembers: RawRelayMember[] = []; const mockSockets = new Map(); +let mockWebsocketUnavailable = false; +const relayWebsocketConnectAttemptStarts: number[] = []; let mockWebsocketSendMutexWedged = false; let mockClosedChannelLiveSubscription = false; const realSockets = new Map(); @@ -8932,6 +8939,7 @@ async function resolveGetEvent( } async function connectRealSocket(args: { url?: string; onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); const wsId = nextSocketId++; const ws = new WebSocket(args.url ?? DEFAULT_RELAY_WS_URL); const handler = resolveHandler(args.onMessage); @@ -8960,6 +8968,10 @@ async function connectRealSocket(args: { url?: string; onMessage: unknown }) { } async function connectMockSocket(args: { onMessage: unknown }) { + relayWebsocketConnectAttemptStarts.push(Date.now()); + if (mockWebsocketUnavailable) { + throw new Error("mock relay unavailable"); + } const connectError = getConfig()?.mock?.websocketConnectErrors?.shift(); if (connectError) { throw new Error(connectError); @@ -9405,6 +9417,8 @@ export function maybeInstallE2eTauriMocks() { } mockClosedChannelLiveSubscription = false; + mockWebsocketUnavailable = false; + relayWebsocketConnectAttemptStarts.length = 0; mockGlobalAgentConfig = config.mock?.globalAgentConfig ? { ...config.mock.globalAgentConfig } : null; @@ -9628,6 +9642,16 @@ export function maybeInstallE2eTauriMocks() { } return sockets.length; }; + window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__ = (unavailable) => { + mockWebsocketUnavailable = unavailable; + if (unavailable) relayWebsocketConnectAttemptStarts.length = 0; + }; + window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ + ...relayWebsocketConnectAttemptStarts, + ]; + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { + relayWebsocketConnectAttemptStarts.length = 0; + }; // Tests vary mesh admission and models to exercise provider discovery and // the managed-agent start preflight. window.__BUZZ_E2E_SET_MESH__ = (mesh) => { diff --git a/desktop/tests/e2e/helpers/twoRelayHarness.ts b/desktop/tests/e2e/helpers/twoRelayHarness.ts index 98acd86398..43eb458417 100644 --- a/desktop/tests/e2e/helpers/twoRelayHarness.ts +++ b/desktop/tests/e2e/helpers/twoRelayHarness.ts @@ -162,6 +162,7 @@ export class TwoRelayHarness { BUZZ_METRICS_PORT: String(relay.ports.metrics), BUZZ_REQUIRE_AUTH_TOKEN: "false", BUZZ_RECONCILE_CHANNELS: "true", + BUZZ_AUTO_MIGRATE: "true", }); await this.waitForHealth(relay, child); } diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 6240cca0ba..67ce725da8 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -49,6 +49,31 @@ async function restartMockWebsockets(page: import("@playwright/test").Page) { expect(restarted).toBeGreaterThan(0); } +async function setMockWebsocketUnavailable( + page: import("@playwright/test").Page, + unavailable: boolean, +) { + await page.evaluate((value) => { + const setUnavailable = window.__BUZZ_E2E_SET_MOCK_WEBSOCKET_UNAVAILABLE__; + if (!setUnavailable) { + throw new Error("E2E websocket availability seam is not installed."); + } + setUnavailable(value); + }, unavailable); +} + +async function getMockWebsocketConnectAttempts( + page: import("@playwright/test").Page, +) { + return page.evaluate(() => { + const getAttempts = window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__; + if (!getAttempts) { + throw new Error("E2E websocket attempt seam is not installed."); + } + return getAttempts(); + }); +} + async function emitMockMessages( page: import("@playwright/test").Page, messages: Array<{ content: string; createdAt: number }>, @@ -121,6 +146,55 @@ test("failed initial relay dial retries automatically", async ({ page }) => { await expect(page.getByTestId("channel-general")).toBeVisible(); }); +test("routine traffic cannot bypass outage backoff and recovery stays automatic", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await setMockWebsocketUnavailable(page, true); + await disconnectMockWebsockets(page); + + // Exercise the production query path throughout the outage. Before the + // coordinator fix, each rejected query called ensureConnected(), cancelled + // the scheduled timer, and dialed immediately. The fixed session keeps these + // callers behind its single jittered exponential-backoff attempt. + await page.evaluate(async () => { + const deadline = Date.now() + 4_200; + while (Date.now() < deadline) { + await window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }); + + const attempts = await getMockWebsocketConnectAttempts(page); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(3); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } + + await setMockWebsocketUnavailable(page, false); + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 10_000 }, + ) + .toBe("connected"); + + const afterRecovery = `automatic outage recovery ${Date.now()}`; + await emitMockMessages(page, [ + { content: afterRecovery, createdAt: Math.floor(Date.now() / 1_000) }, + ]); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("message-timeline")).toContainText( + afterRecovery, + ); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], diff --git a/desktop/tests/e2e/relay-restart.live.spec.ts b/desktop/tests/e2e/relay-restart.live.spec.ts index 057ef46f52..f3c80e69a1 100644 --- a/desktop/tests/e2e/relay-restart.live.spec.ts +++ b/desktop/tests/e2e/relay-restart.live.spec.ts @@ -1,8 +1,13 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + import { expect, test, type Page } from "@playwright/test"; -import { installBridge } from "../helpers/bridge"; +import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; import { TwoRelayHarness, type RelaySpec } from "./helpers/twoRelayHarness"; +const exec = promisify(execFile); + // Live gate: boots a REAL buzz-relay process, points the app at it, SIGTERMs // the relay mid-session, restarts it on the same port, and asserts the client // converges back to "connected". This proves the full restart story end to @@ -20,6 +25,55 @@ function required(name: string, value: string | undefined): string { return value; } +async function runCli(args: string[], relayUrl: string, privateKey: string) { + const binary = required("BUZZ_E2E_CLI_BIN", process.env.BUZZ_E2E_CLI_BIN); + const { stdout } = await exec(binary, args, { + cwd: "..", + env: { + ...process.env, + BUZZ_AUTH_TAG: "", + BUZZ_PRIVATE_KEY: privateKey, + BUZZ_RELAY_URL: relayUrl, + }, + }); + return stdout; +} + +async function seedLiveChannel(relayUrl: string) { + const name = `reconnect-live-${process.pid}`; + const created = JSON.parse( + await runCli( + [ + "channels", + "create", + "--name", + name, + "--type", + "stream", + "--visibility", + "open", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ), + ) as { channel_id: string }; + await runCli( + [ + "channels", + "add-member", + "--channel", + created.channel_id, + "--pubkey", + TEST_IDENTITIES.tyler.pubkey, + "--role", + "member", + ], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + return { id: created.channel_id, name }; +} + async function connectionState(page: Page): Promise { return page.evaluate(() => { const win = window as Window & { @@ -29,13 +83,61 @@ async function connectionState(page: Page): Promise { }); } +async function exerciseBackgroundTraffic(page: Page, durationMs: number) { + await page.evaluate(async (duration) => { + const deadline = Date.now() + duration; + while (Date.now() < deadline) { + void window.__BUZZ_E2E_QUERY_CLIENT__?.invalidateQueries({ + queryKey: ["channels"], + }); + await new Promise((resolve) => window.setTimeout(resolve, 100)); + } + }, durationMs); +} + +async function resetConnectAttempts(page: Page) { + await page.evaluate(() => { + window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?.(); + }); +} + +async function assertConnectAttemptsArePaced(page: Page) { + const attempts = await page.evaluate( + () => window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?.() ?? [], + ); + expect(attempts.length).toBeGreaterThanOrEqual(2); + expect(attempts.length).toBeLessThanOrEqual(4); + for (let index = 1; index < attempts.length; index += 1) { + expect(attempts[index] - attempts[index - 1]).toBeGreaterThanOrEqual(700); + } +} + +async function proveLiveDelivery( + page: Page, + relayUrl: string, + channel: { id: string; name: string }, + label: string, +) { + await page.getByTestId(`channel-${channel.name}`).click(); + await expect(page.getByTestId("chat-title")).toHaveText(channel.name); + const message = `${label} ${Date.now()}`; + await runCli( + ["messages", "send", "--channel", channel.id, "--content", message], + relayUrl, + TEST_IDENTITIES.alice.privateKey, + ); + await expect(page.getByTestId("message-timeline")).toContainText(message, { + timeout: 30_000, + }); +} + test.describe("relay restart live gate", () => { test.skip(!enabled, "set BUZZ_E2E_RELAY_RESTART=1 to run live gate"); test("client reconnects after the relay is SIGTERMed and restarted", async ({ page, }) => { - test.setTimeout(180_000); + test.setTimeout(240_000); const portBase = 26_000 + (process.pid % 3_000); const spec: RelaySpec = { name: "relay-restart", @@ -56,6 +158,8 @@ test.describe("relay restart live gate", () => { await harness.startRelays(); const relayHttpUrl = `http://127.0.0.1:${spec.ports.main}`; + const channel = await test.step("seed live channel and membership", () => + seedLiveChannel(relayHttpUrl)); await installBridge(page, { mode: "relay", user: "tyler", @@ -64,28 +168,68 @@ test.describe("relay restart live gate", () => { }); await page.goto("/"); - // Baseline: the app converges to a live authenticated session. - await expect - .poll(() => connectionState(page), { timeout: 60_000 }) - .toBe("connected"); + // Baseline: the app converges to a live authenticated session and sees + // the channel created for this fresh database. + await test.step("wait for initial authenticated connection", async () => { + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await expect(page.getByTestId(`channel-${channel.name}`)).toBeVisible({ + timeout: 30_000, + }); + }); // Roll the pod. Graceful drain: readiness 503 → 5s grace → 1012 close // broadcast → process exit. The client must observe the close (not a // silent stall) and start retrying. + await resetConnectAttempts(page); await harness.terminateRelayGracefully(spec.name); await expect .poll(() => connectionState(page), { timeout: 30_000 }) .not.toBe("connected"); + // Keep the real relay unavailable across several reconnect windows while + // ordinary app traffic continues. This is the production-shaped race: + // background queries must not bypass the session coordinator's backoff. + await exerciseBackgroundTraffic(page, 8_000); + await expect.poll(() => connectionState(page)).not.toBe("connected"); + await assertConnectAttemptsArePaced(page); + // Bring the "new pod" up on the same address, exactly like a k8s // restart behind a stable service endpoint. await harness.restartRelay(spec.name); // The client's retry loop must find the fresh relay and converge back - // to connected without any user interaction. + // to connected without any user interaction, then prove that AUTH and + // live-subscription replay finished by receiving an event published by + // a second identity through the real CLI/relay boundary. await expect .poll(() => connectionState(page), { timeout: 60_000 }) .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "first automatic recovery", + ); + + // Flap the fresh pod once more. A second recovery catches stale timer, + // waiter, generation, and subscription state that a single cycle cannot. + await harness.terminateRelayGracefully(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 30_000 }) + .not.toBe("connected"); + await exerciseBackgroundTraffic(page, 4_000); + await harness.restartRelay(spec.name); + await expect + .poll(() => connectionState(page), { timeout: 60_000 }) + .toBe("connected"); + await proveLiveDelivery( + page, + relayHttpUrl, + channel, + "second automatic recovery", + ); } catch (error) { console.error(await harness.logs()); throw error; From 1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 12:27:56 -0600 Subject: [PATCH 13/87] feat(release): make desktop releases immutable (#3568) ## Summary - add a manual desktop release preparer that regenerates one version-only candidate from current `origin/main` - validate deterministic complete changelog accounting, candidate authorship, allowed files, exact-head approval, required checks, and two-parent merge topology before tagging the reviewed candidate - move desktop tags/releases from `v*` to `desktop-v*` while preserving relay, chart, push-chart, and mobile behavior - stage all four platform outputs in Actions artifacts and grant GitHub release write access only to one final all-platform-gated publisher - publish the versioned release only after complete artifact assembly; update stable `latest.json` last; never promote prereleases or published rebuild outputs ## Safety properties - desktop tags point to the reviewed candidate SHA, not the merge commit - release builds remain tag-bound and reverify tag == checked-out HEAD - one final writer fails closed on artifact basename collisions - per-tag concurrency serializes publication without cancellation - published reruns do not replace immutable versioned assets or promote signatures from a rebuild - candidate branches use an explicit remote OID lease when regenerated ## Validation - `scripts/test-desktop-release-candidate.sh` - `scripts/test-release-ref-contract.sh` - `scripts/test-mobile-release-contract.sh` - changed workflow YAML parsing (Ruby Psych) - changed shell syntax (`bash -n`) - `git diff --check` - push hooks: branch-skew, Rust workspace tests (1,853 passed), desktop Tauri tests (3 passed) ## Coordinated companion - squareup/buzz-releases#79 updates the manually entered desktop source-tag contract to stable-only `desktop-v*` - merge the private contract companion before the first namespaced desktop release ## Rollout blockers (no settings changed here) Before the first candidate/release: 1. enable merge commits in repository settings 2. allow `merge` in ruleset `13596885` 3. require approval after the last push in ruleset `13596885` 4. include `refs/tags/desktop-v*` explicitly in release ruleset `14378754` 5. prove the non-publishing candidate/merge/tag/artifact validation path before any production release Do not test the old workflow with a prerelease: it can still mutate the production rolling updater release. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 43 ++- .github/workflows/ci.yml | 2 + .github/workflows/prepare-desktop-release.yml | 38 +++ .github/workflows/release.yml | 318 ++++++++---------- Justfile | 4 +- RELEASING.md | 23 +- scripts/desktop_release.py | 214 ++++++++++++ scripts/prepare-desktop-release.sh | 83 +++++ scripts/required-check-succeeded.jq | 14 + scripts/review-decision-approved.jq | 1 + scripts/test-desktop-release-candidate.sh | 84 +++++ scripts/test-release-ref-contract.sh | 75 ++++- scripts/verify-desktop-release-merge.sh | 62 ++++ 13 files changed, 763 insertions(+), 198 deletions(-) create mode 100644 .github/workflows/prepare-desktop-release.yml create mode 100755 scripts/desktop_release.py create mode 100755 scripts/prepare-desktop-release.sh create mode 100644 scripts/required-check-succeeded.jq create mode 100644 scripts/review-decision-approved.jq create mode 100755 scripts/test-desktop-release-candidate.sh create mode 100755 scripts/verify-desktop-release-merge.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index db34fddc2c..a69eafb404 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -4,7 +4,7 @@ name: Auto-tag on Release PR Merge # prefix; the main chart lane also auto-detects a Chart.yaml version bump so # a chart feature PR can publish its own new version when merged: # -# version-bump/ → tag v → release.yml (desktop app) +# version-bump/ → tag desktop-v → release.yml (desktop app) # relay-release/ → tag relay-v → docker.yml (relay image) # chart-release/ → tag chart-v → helm-chart.yml (main helm chart) # push-chart-release/ → tag push-chart-v → push-gateway-helm-chart.yml @@ -35,6 +35,11 @@ permissions: jobs: auto-tag: + permissions: + contents: read + pull-requests: read + checks: read + statuses: read if: > github.event.pull_request.merged == true && github.event.pull_request.head.repo.full_name == github.repository @@ -57,7 +62,7 @@ jobs: case "$BRANCH" in version-bump/*) VERSION="${BRANCH#version-bump/}" - TAG_PREFIX="v" ;; + TAG_PREFIX="desktop-v" ;; relay-release/*) VERSION="${BRANCH#relay-release/}" TAG_PREFIX="relay-v" ;; @@ -85,9 +90,34 @@ jobs: { echo "enabled=true" echo "tag=${TAG_PREFIX}${VERSION}" + if [[ "$TAG_PREFIX" == desktop-v ]]; then + echo "target_sha=${{ github.event.pull_request.head.sha }}" + echo "desktop=true" + else + echo "target_sha=$GITHUB_SHA" + echo "desktop=false" + fi } >> "$GITHUB_OUTPUT" echo "Tagging ${TAG_PREFIX}${VERSION}" + + - name: Verify immutable reviewed desktop candidate + if: steps.release.outputs.desktop == 'true' + env: + GH_TOKEN: ${{ github.token }} + VERSION: ${{ steps.release.outputs.tag }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} + PR_BASE_REF: ${{ github.event.pull_request.base.ref }} + PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} + PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + VERSION="${VERSION#desktop-v}" + export VERSION + scripts/verify-desktop-release-merge.sh + - name: Create release tagger token if: steps.release.outputs.enabled == 'true' id: release-tagger @@ -102,21 +132,22 @@ jobs: env: GH_TOKEN: ${{ steps.release-tagger.outputs.token }} TAG: ${{ steps.release.outputs.tag }} + TARGET_SHA: ${{ steps.release.outputs.target_sha }} run: | set -euo pipefail # Check gh's exit status, not its output. A missing ref returns a 404 # JSON body on stdout, which must not be mistaken for an existing tag. if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then EXISTING_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" - if [ "$EXISTING_SHA" = "$GITHUB_SHA" ]; then - echo "Tag $TAG already exists at $GITHUB_SHA — skipping tag creation" + if [ "$EXISTING_SHA" = "$TARGET_SHA" ]; then + echo "Tag $TAG already exists at $TARGET_SHA — skipping tag creation" exit 0 else - echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $GITHUB_SHA)" + echo "::error::Tag $TAG already exists at $EXISTING_SHA (expected $TARGET_SHA)" exit 1 fi fi gh api --method POST "repos/$GITHUB_REPOSITORY/git/refs" \ -f ref="refs/tags/$TAG" \ - -f sha="$GITHUB_SHA" \ + -f sha="$TARGET_SHA" \ --silent diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4826d985f..59d63f28da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,8 @@ jobs: - '.github/workflows/ci.yml' - name: Release workflow source contract run: scripts/test-release-ref-contract.sh + - name: Desktop release candidate contract + run: scripts/test-desktop-release-candidate.sh - name: Mobile release contract run: | scripts/test-mobile-release-contract.sh diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml new file mode 100644 index 0000000000..7cc480b93b --- /dev/null +++ b/.github/workflows/prepare-desktop-release.yml @@ -0,0 +1,38 @@ +name: Prepare Desktop Release + +on: + workflow_dispatch: + inputs: + version: + description: Semver to prepare (for example 0.5.1) + required: true + +env: + RELEASE_AUTOMATION_NAME: Carl + RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz + +jobs: + prepare: + if: github.repository == 'block/buzz' + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Create short-lived release preparer token + id: preparer + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} + private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} + permission-contents: write + permission-pull-requests: write + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + token: ${{ steps.preparer.outputs.token }} + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Prepare immutable candidate and open or update PR + env: + GH_TOKEN: ${{ steps.preparer.outputs.token }} + VERSION: ${{ inputs.version }} + run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b87e9c8c08..07951ef81d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,14 +1,13 @@ name: Release +concurrency: + group: desktop-release-${{ github.ref }} + cancel-in-progress: false + on: push: tags: - - 'v[0-9]*' - workflow_dispatch: - inputs: - version: - description: "Semver version matching the v-prefixed dispatch tag" - required: true + - 'desktop-v[0-9]*' jobs: # Shared setup: verify the immutable release tag, determine the version, and @@ -19,23 +18,14 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 permissions: - contents: write + contents: read outputs: version: ${{ steps.version.outputs.version }} source_sha: ${{ steps.source.outputs.source_sha }} steps: - name: Determine version id: version - env: - EVENT_NAME: ${{ github.event_name }} - INPUT_VERSION: ${{ inputs.version }} - run: | - if [[ "$EVENT_NAME" == "push" ]]; then - VERSION="${GITHUB_REF_NAME#v}" - else - VERSION="$INPUT_VERSION" - fi - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + run: echo "version=${GITHUB_REF_NAME#desktop-v}" >> "$GITHUB_OUTPUT" - name: Validate version env: @@ -56,42 +46,9 @@ jobs: env: VERSION: ${{ steps.version.outputs.version }} run: | - scripts/verify-release-ref.sh v "$VERSION" + scripts/verify-release-ref.sh desktop-v "$VERSION" echo "source_sha=$(git rev-parse 'HEAD^{commit}')" >> "$GITHUB_OUTPUT" - - name: Create versioned GitHub release - env: - VERSION: ${{ steps.version.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - RELEASE_SHA=$(git rev-parse HEAD) - NOTES="" - if [[ -f CHANGELOG.md ]]; then - NOTES=$(awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found && !/^\$/" CHANGELOG.md) - fi - if [[ -z "$NOTES" ]]; then - NOTES="Buzz Desktop v${VERSION}" - fi - PRERELEASE_FLAGS=() - if [[ "$VERSION" =~ -(test|alpha|beta|rc)([.-]|$) ]]; then - PRERELEASE_FLAGS=(--prerelease --latest=false) - fi - gh release create "v${VERSION}" \ - --target "$RELEASE_SHA" \ - --title "Buzz Desktop v${VERSION}" \ - --notes "$NOTES" \ - "${PRERELEASE_FLAGS[@]}" - - - name: Create rolling auto-update release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release create buzz-desktop-latest \ - --prerelease \ - --title "Buzz Desktop Auto-Update" \ - --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ - 2>/dev/null || true - release: name: Release if: github.repository == 'block/buzz' @@ -99,7 +56,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -114,7 +71,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -272,13 +229,19 @@ jobs: fi echo "dmg=$DMG" >> "$GITHUB_OUTPUT" - # Find the updater .tar.gz and .sig + # Find the updater .tar.gz and .sig. Give each architecture a unique + # release basename before artifacts are merged by the final writer. ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) SIG="${ARCHIVE}.sig" if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_aarch64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -289,23 +252,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload arm64 DMG to versioned GitHub release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.artifacts.outputs.dmg }} - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Apple Silicon release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-arm64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-macos-x64: name: Release macOS (Intel) @@ -314,7 +269,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read id-token: write # required by block/apple-codesign-action for OIDC outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} @@ -330,7 +285,7 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -443,6 +398,11 @@ jobs: echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" exit 1 fi + RENAMED="$(dirname "$ARCHIVE")/Buzz_${VERSION}_x64.app.tar.gz" + mv "$ARCHIVE" "$RENAMED" + mv "$SIG" "${RENAMED}.sig" + ARCHIVE="$RENAMED" + SIG="${RENAMED}.sig" echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" echo "sig=$SIG" >> "$GITHUB_OUTPUT" @@ -453,23 +413,15 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Intel DMG to versioned GitHub release - run: gh release upload "v${VERSION}" "$DMG_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DMG_PATH: ${{ steps.unsigned.outputs.dmg }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Intel macOS release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-macos-x64 + if-no-files-found: error + path: | + ${{ steps.unsigned.outputs.dmg }} + ${{ steps.artifacts.outputs.archive }} + ${{ steps.artifacts.outputs.sig }} release-linux: name: Release Linux @@ -480,7 +432,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read env: # AppImage tools (linuxdeploy, appimagetool) are themselves AppImages. # Containers lack FUSE, so we must use the extract-and-run fallback. @@ -555,7 +507,7 @@ jobs: - name: Verify tag-bound release source env: VERSION: ${{ needs.setup.outputs.version }} - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 @@ -689,29 +641,16 @@ jobs: SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} # NOTE: .deb is NOT auto-updatable (Tauri updater constraint — only AppImage supports it on Linux) - - name: Upload Linux artifacts to versioned GitHub release - env: - VERSION: ${{ needs.setup.outputs.version }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEB_PATH: ${{ steps.linux-artifacts.outputs.deb }} - APPIMAGE_PATH: ${{ steps.linux-artifacts.outputs.appimage }} - run: | - gh release upload "v$VERSION" \ - "$DEB_PATH" \ - "$APPIMAGE_PATH" \ - --clobber - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.linux-artifacts.outputs.archive }} - SIG_PATH: ${{ steps.linux-artifacts.outputs.sig }} + - name: Stage Linux release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-linux-x64 + if-no-files-found: error + path: | + ${{ steps.linux-artifacts.outputs.deb }} + ${{ steps.linux-artifacts.outputs.appimage }} + ${{ steps.linux-artifacts.outputs.archive }} + ${{ steps.linux-artifacts.outputs.sig }} release-windows: name: Release Windows @@ -719,7 +658,7 @@ jobs: needs: setup timeout-minutes: 60 permissions: - contents: write + contents: read outputs: archive_name: ${{ steps.artifacts.outputs.archive_name }} sig: ${{ steps.read-sig.outputs.sig }} @@ -735,7 +674,7 @@ jobs: - name: Verify tag-bound release source shell: bash - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: @@ -745,7 +684,7 @@ jobs: with: node-version: 24.14.1 # Disable dependency caching: a writable cache in this release workflow - # (contents: write, feeds a signed installer) is a poisoning vector. pnpm + # (contents: read, feeds a signed installer) is a poisoning vector. pnpm # install runs uncached below. package-manager-cache: false @@ -827,25 +766,14 @@ jobs: env: SIG_PATH: ${{ steps.artifacts.outputs.sig }} - - name: Upload Windows installer to versioned GitHub release - shell: bash - run: gh release upload "v${VERSION}" "$EXE_PATH" --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - EXE_PATH: ${{ steps.artifacts.outputs.exe }} - - - name: Upload updater archive to rolling release - if: github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) - shell: bash - run: | - gh release upload buzz-desktop-latest \ - "$ARCHIVE_PATH" \ - "$SIG_PATH" \ - --clobber - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} - SIG_PATH: ${{ steps.artifacts.outputs.sig }} + - name: Stage Windows release artifacts + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: desktop-release-windows-x64 + if-no-files-found: error + path: | + ${{ steps.artifacts.outputs.exe }} + ${{ steps.artifacts.outputs.sig }} assemble-manifest: name: Assemble multi-platform latest.json @@ -853,7 +781,11 @@ jobs: if: | always() && needs.setup.result == 'success' && - github.ref == format('refs/tags/v{0}', needs.setup.outputs.version) + needs.release.result == 'success' && + needs.release-macos-x64.result == 'success' && + needs.release-linux.result == 'success' && + needs.release-windows.result == 'success' && + github.ref == format('refs/tags/desktop-v{0}', needs.setup.outputs.version) runs-on: ubuntu-latest needs: [setup, release, release-macos-x64, release-linux, release-windows] timeout-minutes: 10 @@ -870,7 +802,26 @@ jobs: persist-credentials: false - name: Verify tag-bound release source - run: scripts/verify-release-ref.sh v "$VERSION" + run: scripts/verify-release-ref.sh desktop-v "$VERSION" + + - name: Download staged release artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: desktop-release-* + path: staged-by-platform + + - name: Flatten staged artifacts without basename collisions + run: | + set -euo pipefail + mkdir staged + while IFS= read -r -d '' file; do + name="$(basename "$file")" + [[ ! -e "staged/$name" ]] || { + echo "::error::release artifact basename collision: $name" + exit 1 + } + cp "$file" "staged/$name" + done < <(find staged-by-platform -type f -print0) - name: Write signature files env: @@ -899,7 +850,7 @@ jobs: write_sig "$RESULT_LINUX" linux-x86_64 "$SIG_LINUX" write_sig "$RESULT_WIN" windows-x86_64 "$SIG_WIN" - - name: Verify archive URLs are accessible + - name: Verify draft release has every updater archive env: RESULT_ARM64: ${{ needs.release.result }} RESULT_X64: ${{ needs.release-macos-x64.result }} @@ -911,39 +862,19 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" - ARCHIVES=() - - add_archive() { - local result="$1" platform="$2" archive="$3" - if [[ "$result" == "success" ]]; then - [[ -n "$archive" ]] || { echo "::error::Missing archive name for successful platform: $platform"; exit 1; } - ARCHIVES+=("$archive") - fi - } - - add_archive "$RESULT_ARM64" darwin-aarch64 "$ARCHIVE_ARM64" - add_archive "$RESULT_X64" darwin-x86_64 "$ARCHIVE_X64" - add_archive "$RESULT_LINUX" linux-x86_64 "$ARCHIVE_LINUX" - add_archive "$RESULT_WIN" windows-x86_64 "$ARCHIVE_WIN" - - for name in "${ARCHIVES[@]}"; do - echo "Checking $BASE/$name ..." - success=false - for attempt in 1 2 3; do - if curl -fsI "$BASE/$name" > /dev/null 2>&1; then - success=true - break - fi - echo "Attempt $attempt failed for $name, retrying in 10s..." - sleep 10 - done - if [ "$success" != "true" ]; then - echo "::error::Archive not accessible after 3 attempts: $BASE/$name" - exit 1 + assets=$(find staged -type f -exec basename {} \;) + for spec in \ + "$RESULT_ARM64:$ARCHIVE_ARM64" \ + "$RESULT_X64:$ARCHIVE_X64" \ + "$RESULT_LINUX:$ARCHIVE_LINUX" \ + "$RESULT_WIN:$ARCHIVE_WIN"; do + result="${spec%%:*}" + archive="${spec#*:}" + if [[ "$result" == success ]]; then + [[ -n "$archive" ]] || { echo "::error::successful platform has no archive"; exit 1; } + grep -Fxq "$archive" <<<"$assets" || { echo "::error::draft release missing $archive"; exit 1; } fi done - echo "All archive URLs verified." - name: Generate unified latest.json env: @@ -957,7 +888,7 @@ jobs: ARCHIVE_WIN: ${{ needs.release-windows.outputs.archive_name }} run: | set -euo pipefail - BASE="https://github.com/block/buzz/releases/download/buzz-desktop-latest" + BASE="https://github.com/block/buzz/releases/download/desktop-v${VERSION}" TRIPLES=() add_triple() { @@ -977,6 +908,45 @@ jobs: bash desktop/scripts/generate-oss-latest-json.sh "$VERSION" "${TRIPLES[@]}" > latest.json cat latest.json - - name: Upload latest.json to rolling release + - name: Create or verify versioned draft run: | - gh release upload buzz-desktop-latest latest.json --clobber + set -euo pipefail + NOTES_FILE="${RUNNER_TEMP}/release-notes.md" + awk "/^## v${VERSION}\$/{found=1; next} found && /^## v/{exit} found" CHANGELOG.md > "$NOTES_FILE" + [[ -s "$NOTES_FILE" ]] || { echo "::error::missing non-empty changelog block for v${VERSION}"; exit 1; } + PRERELEASE_FLAGS=() + if [[ "$VERSION" == *-* ]]; then + PRERELEASE_FLAGS=(--prerelease --latest=false) + fi + if gh release view "desktop-v${VERSION}" >/dev/null 2>&1; then + EXISTING_SHA=$(gh release view "desktop-v${VERSION}" --json targetCommitish --jq .targetCommitish) + IS_DRAFT=$(gh release view "desktop-v${VERSION}" --json isDraft --jq .isDraft) + [[ "$EXISTING_SHA" == "${{ needs.setup.outputs.source_sha }}" ]] || { + echo "::error::existing release targets $EXISTING_SHA, not the immutable source"; exit 1; + } + if [[ "$IS_DRAFT" != true ]]; then + echo "already_published=true" >> "$GITHUB_ENV" + fi + else + gh release create "desktop-v${VERSION}" \ + --draft \ + --target "${{ needs.setup.outputs.source_sha }}" \ + --title "Buzz Desktop v${VERSION}" \ + --notes-file "$NOTES_FILE" \ + "${PRERELEASE_FLAGS[@]}" + fi + + - name: Upload complete artifact set to versioned draft + if: env.already_published != 'true' + run: | + mapfile -t files < <(find staged -type f -print) + [[ "${#files[@]}" -gt 0 ]] || { echo "::error::no staged release artifacts"; exit 1; } + gh release upload "desktop-v${VERSION}" "${files[@]}" --clobber + + - name: Publish complete versioned release + if: env.already_published != 'true' + run: gh release edit "desktop-v${VERSION}" --draft=false + + - name: Upload latest.json to rolling release last + if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/Justfile b/Justfile index a2fa408e7f..2d76f1a7b9 100644 --- a/Justfile +++ b/Justfile @@ -725,7 +725,7 @@ bump-relay-version version: cargo update -p buzz-relay echo "Bumped buzz-relay to {{ version }} and regenerated Cargo.lock" -# Open or update the desktop release PR (signed desktop app) +# Open or update the desktop release PR from an immutable origin/main snapshot release-desktop *ARGS: #!/usr/bin/env bash set -euo pipefail @@ -735,7 +735,7 @@ release-desktop *ARGS: else VERSION="$ARG" fi - just _release-pr desktop "$VERSION" + scripts/prepare-desktop-release.sh "$VERSION" # Open or update the relay release PR (ghcr.io/block/buzz image) release-relay *ARGS: diff --git a/RELEASING.md b/RELEASING.md index 063b813e2c..45f0f8638f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -31,7 +31,7 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop and relay releases use metadata PRs. Mobile does not. Each +Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,12 +42,11 @@ or mobile GitHub Release. ### Desktop -1. **`just release-desktop`** runs locally on `main`, creates or updates a - `version-bump/` PR, bumps the desktop manifests, regenerates - lockfiles, and updates `CHANGELOG.md`. -2. **Merge the PR.** `auto-tag-on-release-pr-merge` pushes `v`. -3. **The tag triggers `release.yml`.** It builds, signs, notarizes, and - publishes the desktop app for macOS and Linux. +1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. +2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. +4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. +5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. ### Relay @@ -147,8 +146,8 @@ for distributable builds or builds from an immutable release tag. ## Manual Release Retry The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `v` tag. Select that tag in the ref picker and -provide the matching semver version without the `v` prefix. It cannot build +existing immutable `desktop-v` tag. Select that tag in the ref picker and +provide the matching semver version without the `desktop-v` prefix. It cannot build from `main` or another caller-selected source ref. Mobile intentionally has no branch or arbitrary-ref fallback. The private @@ -171,7 +170,7 @@ for the private pipeline contract. Desktop publishes two GitHub releases: -1. **`v`**: the user-facing release with installers. +1. **`desktop-v`**: the user-facing release with installers. 2. **`buzz-desktop-latest`**: the rolling auto-updater release. Mobile publishes only annotated `mobile-vX.Y.Z-rc.N` git tags. Store artifacts @@ -186,7 +185,7 @@ The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel (`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and `.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `v` release. Intel users download the `_x64.dmg`. +the same `desktop-v` release. Intel users download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on diff --git a/scripts/desktop_release.py b/scripts/desktop_release.py new file mode 100755 index 0000000000..d6518b26b1 --- /dev/null +++ b/scripts/desktop_release.py @@ -0,0 +1,214 @@ +#!/usr/bin/env python3 +"""Generate and validate immutable desktop release candidates.""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +CHANGELOG = ROOT / "CHANGELOG.md" +METADATA = ROOT / ".release" / "desktop-candidate.json" +SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$") +DESKTOP_PATHS = ( + "desktop/", + "crates/buzz-core/", + "crates/buzz-persona/", + "crates/buzz-sdk/", + "crates/buzz-agent/", + "crates/buzz-media/", +) +CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", + "desktop/src-tauri/Cargo.lock", + "pnpm-lock.yaml", +} +REQUIRED_CANDIDATE_FILES = { + ".release/desktop-candidate.json", + "CHANGELOG.md", + "desktop/package.json", + "desktop/src-tauri/tauri.conf.json", + "desktop/src-tauri/Cargo.toml", +} + + +def git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def commit_list(range_spec: str, paths: tuple[str, ...] | None = None) -> list[dict[str, str]]: + args = ["log", range_spec, "--no-merges", "--format=%H%x00%s"] + if paths: + args += ["--", *paths] + out = git(*args) + if not out: + return [] + return [dict(zip(("sha", "subject"), line.split("\0", 1))) for line in out.splitlines()] + + +def stable_tags(base_sha: str) -> list[tuple[int, str, str]]: + tags: list[tuple[int, str, str]] = [] + for tag in git("tag", "--merged", base_sha, "--list").splitlines(): + if not re.fullmatch(r"(?:desktop-)?v[0-9]+\.[0-9]+\.[0-9]+", tag): + continue + sha = git("rev-list", "-n", "1", tag) + distance = int(git("rev-list", "--count", f"{sha}..{base_sha}")) + tags.append((distance, tag, sha)) + return tags + + +def previous_tag(base_sha: str) -> str: + tags = stable_tags(base_sha) + if not tags: + return "" + min_distance = min(item[0] for item in tags) + nearest = [item for item in tags if item[0] == min_distance] + commits = {item[2] for item in nearest} + if len(commits) != 1: + detail = ", ".join(f"{tag}@{sha}" for _, tag, sha in nearest) + raise SystemExit(f"ambiguous previous desktop release tags: {detail}") + # During migration, prefer the namespaced tag when aliases share a commit. + nearest.sort(key=lambda item: (not item[1].startswith("desktop-v"), item[1])) + return nearest[0][1] + + +def bullet(commit: dict[str, str], repo: str) -> str: + sha, subject = commit["sha"], commit["subject"] + short = sha[:12] + pr_match = re.search(r" \(#([0-9]+)\)$", subject) + if pr_match: + pr = pr_match.group(1) + subject = subject[: pr_match.start()] + return f"- {subject} ([#{pr}](https://github.com/{repo}/pull/{pr})) ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + return f"- {subject} ([`{sha}`](https://github.com/{repo}/commit/{sha}))" + + +def expected(base_sha: str, previous: str) -> tuple[list[dict[str, str]], list[dict[str, str]]]: + # With no prior desktop tag, account for the repository's root commit too. + # A ``root..base`` range silently drops that first commit. + range_spec = f"{previous}..{base_sha}" if previous else base_sha + all_commits = commit_list(range_spec) + relevant_shas = {c["sha"] for c in commit_list(range_spec, DESKTOP_PATHS)} + relevant = [c for c in all_commits if c["sha"] in relevant_shas] + other = [c for c in all_commits if c["sha"] not in relevant_shas] + return relevant, other + + +def render(version: str, base_sha: str, previous: str, repo: str) -> tuple[str, list[str]]: + relevant, other = expected(base_sha, previous) + lines = [f"## v{version}", "", "### Desktop and shared changes", ""] + lines += [bullet(c, repo) for c in relevant] or ["- None"] + lines += ["", "### Other repository changes", ""] + lines += [bullet(c, repo) for c in other] or ["- None"] + compare_start = previous or git("rev-list", "--max-parents=0", base_sha).splitlines()[0] + lines += ["", f"[Compare {compare_start}...desktop-v{version}](https://github.com/{repo}/compare/{compare_start}...desktop-v{version})"] + return "\n".join(lines) + "\n", [c["sha"] for c in relevant + other] + + +def generate(args: argparse.Namespace) -> None: + if not SEMVER.fullmatch(args.version): + raise SystemExit(f"invalid semver: {args.version}") + base_sha = git("rev-parse", args.base) + previous = previous_tag(base_sha) + repo = args.repo or re.sub(r".*github\.com[:/]", "", git("remote", "get-url", "origin")).removesuffix(".git") + block, commits = render(args.version, base_sha, previous, repo) + old = CHANGELOG.read_text() if CHANGELOG.exists() else "# Changelog\n" + if not old.startswith("# Changelog"): + raise SystemExit("CHANGELOG.md must begin with '# Changelog'") + remainder = old.split("\n", 1)[1].lstrip("\n") if "\n" in old else "" + CHANGELOG.write_text(f"# Changelog\n\n{block}\n{remainder}") + METADATA.parent.mkdir(parents=True, exist_ok=True) + METADATA.write_text(json.dumps({ + "schema": 1, + "version": args.version, + "base_sha": base_sha, + "previous_tag": previous or None, + "tag": f"desktop-v{args.version}", + "commit_count": len(commits), + }, indent=2) + "\n") + + +def validate(args: argparse.Namespace) -> None: + data = json.loads(METADATA.read_text()) + version = args.version or data["version"] + if data != {**data, "version": version}: + raise SystemExit("candidate version does not match metadata") + if data["tag"] != f"desktop-v{version}": + raise SystemExit("candidate tag does not match version") + candidate = git("rev-parse", args.candidate) + parents = git("show", "-s", "--format=%P", candidate).split() + if len(parents) != 1 or parents[0] != data["base_sha"]: + raise SystemExit("candidate must be one commit directly above recorded base_sha") + changed = set(git("diff-tree", "--no-commit-id", "--name-only", "-r", candidate).splitlines()) + unexpected = changed - CANDIDATE_FILES + missing = REQUIRED_CANDIDATE_FILES - changed + if unexpected or missing: + detail = [] + if unexpected: + detail.append(f"unexpected files: {', '.join(sorted(unexpected))}") + if missing: + detail.append(f"missing required files: {', '.join(sorted(missing))}") + raise SystemExit("candidate is not version-only (" + "; ".join(detail) + ")") + previous = data["previous_tag"] or "" + actual_previous = previous_tag(data["base_sha"]) + if previous != actual_previous: + raise SystemExit( + f"recorded previous tag {previous or ''} does not match " + f"nearest release tag {actual_previous or ''}" + ) + repo = args.repo or "block/buzz" + expected_block, shas = render(version, data["base_sha"], previous, repo) + text = CHANGELOG.read_text() + blocks = re.findall(rf"(?ms)^## v{re.escape(version)}\n.*?(?=^## v|\Z)", text) + if len(blocks) != 1: + raise SystemExit(f"expected exactly one changelog block for v{version}") + if blocks[0].rstrip() != expected_block.rstrip(): + raise SystemExit("changelog block is not deterministic for recorded candidate base") + found = re.findall(r"\[`([0-9a-f]{40})`\]", blocks[0]) + if len(found) != len(set(found)) or set(found) != set(shas) or len(found) != data["commit_count"]: + raise SystemExit("changelog does not account for every expected non-merge commit exactly once") + manifests = { + ROOT / "desktop/package.json": json.loads((ROOT / "desktop/package.json").read_text())["version"], + ROOT / "desktop/src-tauri/tauri.conf.json": json.loads((ROOT / "desktop/src-tauri/tauri.conf.json").read_text())["version"], + } + cargo = re.search(r'(?m)^version = "([^"]+)"', (ROOT / "desktop/src-tauri/Cargo.toml").read_text()) + manifests[ROOT / "desktop/src-tauri/Cargo.toml"] = cargo.group(1) if cargo else "" + bad = [str(path.relative_to(ROOT)) for path, value in manifests.items() if value != version] + if bad: + raise SystemExit(f"version mismatch in: {', '.join(bad)}") + author = git("show", "-s", "--format=%an <%ae>", candidate) + body = git("show", "-s", "--format=%B", candidate) + if author != "Wes ": + raise SystemExit(f"unexpected candidate author: {author}") + if "Signed-off-by: Wes " not in body: + raise SystemExit("candidate is missing Wes Signed-off-by trailer") + if not re.search(r"(?m)^Co-authored-by: .+ <.+>$", body): + raise SystemExit("candidate is missing automation Co-authored-by trailer") + print(f"validated immutable desktop candidate {candidate} for desktop-v{version}") + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + gen = sub.add_parser("generate") + gen.add_argument("version") + gen.add_argument("--base", required=True) + gen.add_argument("--repo") + val = sub.add_parser("validate") + val.add_argument("--candidate", default="HEAD") + val.add_argument("--version") + val.add_argument("--repo") + args = parser.parse_args() + generate(args) if args.command == "generate" else validate(args) + + +if __name__ == "__main__": + main() diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh new file mode 100755 index 0000000000..c477a84cda --- /dev/null +++ b/scripts/prepare-desktop-release.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${1:-}" +mode="${2:-publish}" +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { + echo "usage: $0 [publish|validate-only]" >&2 + exit 1 +} + +remote="${RELEASE_REMOTE:-origin}" +git fetch "$remote" refs/heads/main:refs/remotes/origin/main --no-tags +git fetch "$remote" '+refs/tags/v*:refs/tags/v*' '+refs/tags/desktop-v*:refs/tags/desktop-v*' +base_sha="$(git rev-parse refs/remotes/origin/main)" +branch="version-bump/$version" + +remote_branch="refs/heads/$branch" +remote_oid="" +if remote_oid="$(git ls-remote "$remote" "$remote_branch" | awk '{print $1}')" && [[ -n "$remote_oid" ]]; then + git fetch "$remote" "$remote_branch:refs/remotes/origin/$branch" +fi + +git checkout -B "$branch" "$base_sha" +just bump-desktop-version "$version" +scripts/desktop_release.py generate "$version" --base "$base_sha" --repo block/buzz + +git add \ + .release/desktop-candidate.json \ + CHANGELOG.md \ + desktop/package.json \ + desktop/src-tauri/tauri.conf.json \ + desktop/src-tauri/Cargo.toml \ + desktop/src-tauri/Cargo.lock \ + pnpm-lock.yaml + +agent_name="${RELEASE_AUTOMATION_NAME:-${AGENT_NAME:-Release Automation}}" +agent_email="${RELEASE_AUTOMATION_EMAIL:-${AGENT_EMAIL:-release-automation@users.noreply.github.com}}" +msg="$(mktemp)" +trap 'rm -f "$msg"' EXIT +cat >"$msg" < +EOF +git -c user.name='Wes' -c user.email='wesbillman@users.noreply.github.com' \ + commit -s -F "$msg" +scripts/desktop_release.py validate --candidate HEAD --version "$version" --repo block/buzz + +candidate_sha="$(git rev-parse HEAD)" +previous_tag="$(python3 -c 'import json; print(json.load(open(".release/desktop-candidate.json"))["previous_tag"] or "initial")')" +printf 'base_sha=%s\ncandidate_sha=%s\nprevious_tag=%s\ntag=desktop-v%s\n' \ + "$base_sha" "$candidate_sha" "$previous_tag" "$version" + +if [[ "$mode" == validate-only ]]; then + exit 0 +fi +[[ "$mode" == publish ]] || { echo "unknown mode: $mode" >&2; exit 1; } +if [[ -n "$remote_oid" ]]; then + git push --force-with-lease="$remote_branch:$remote_oid" "$remote" "HEAD:$remote_branch" +else + git push --force-with-lease="$remote_branch:" "$remote" "HEAD:$remote_branch" +fi + +body="$(mktemp)" +trap 'rm -f "$msg" "$body"' EXIT +cat >"$body" < "$tmp/desktop/package.json" +printf '{"version":"1.0.0"}\n' > "$tmp/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "1.0.0"\n' > "$tmp/desktop/src-tauri/Cargo.toml" +echo '# Changelog' > "$tmp/CHANGELOG.md" +echo first > "$tmp/desktop/feature" +git -C "$tmp" add . +git -C "$tmp" commit -qm 'feat: first desktop change' +git -C "$tmp" -c tag.gpgSign=false tag v1.0.0 +echo second >> "$tmp/desktop/feature" +git -C "$tmp" commit -qam 'fix: desktop fix' +echo policy > "$tmp/POLICY.md" +git -C "$tmp" add POLICY.md +git -C "$tmp" commit -qm 'docs: repository policy' +base=$(git -C "$tmp" rev-parse HEAD) +( + cd "$tmp" + scripts/desktop_release.py generate 1.0.1 --base "$base" --repo block/buzz + python3 - <<'PY' +import json +for path in ('desktop/package.json', 'desktop/src-tauri/tauri.conf.json'): + data=json.load(open(path)); data['version']='1.0.1'; open(path,'w').write(json.dumps(data)+'\n') +p='desktop/src-tauri/Cargo.toml'; open(p,'w').write('[package]\nversion = "1.0.1"\n') +PY + rm -f msg + git add . + cat >msg <<'EOF' +chore(release): release Buzz Desktop version 1.0.1 + +Co-authored-by: Test Automation +EOF + git -c user.name=Wes -c user.email=wesbillman@users.noreply.github.com commit -q -s -F msg + rm msg + scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz + grep -Fq '### Other repository changes' CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~1)" CHANGELOG.md + grep -Fq "$(git rev-parse HEAD~2)" CHANGELOG.md + + # Metadata cannot lie about the prior release boundary. + cp .release/desktop-candidate.json metadata.json + python3 - <<'PY' +import json +p='.release/desktop-candidate.json'; d=json.load(open(p)); d['previous_tag']=None; open(p,'w').write(json.dumps(d)+'\n') +PY + if scripts/desktop_release.py validate --version 1.0.1 --repo block/buzz >/dev/null 2>&1; then + echo "validator accepted a forged previous release tag" >&2 + exit 1 + fi + mv metadata.json .release/desktop-candidate.json +) + +# An initial release must account for the root commit, not silently omit it. +initial=$(mktemp -d) +cp "$repo_root/scripts/desktop_release.py" "$initial/desktop_release.py" +git -C "$initial" init -q +git -C "$initial" config user.name test +git -C "$initial" config user.email test@example.com +mkdir -p "$initial/scripts" "$initial/desktop/src-tauri" +mv "$initial/desktop_release.py" "$initial/scripts/desktop_release.py" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/package.json" +printf '{"version":"0.1.0"}\n' > "$initial/desktop/src-tauri/tauri.conf.json" +printf '[package]\nversion = "0.1.0"\n' > "$initial/desktop/src-tauri/Cargo.toml" +printf '# Changelog\n' > "$initial/CHANGELOG.md" +echo root > "$initial/ROOT.md" +git -C "$initial" add . +git -C "$initial" commit -qm 'feat: root release content' +root_sha=$(git -C "$initial" rev-parse HEAD) +(cd "$initial" && scripts/desktop_release.py generate 0.1.0 --base "$root_sha" --repo block/buzz) +grep -Fq "$root_sha" "$initial/CHANGELOG.md" +rm -rf "$initial" + +echo "desktop release candidate contract passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 0d810819d9..bd4eb75275 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -12,16 +12,16 @@ git -C "$tmp" config user.email test@example.com echo first >"$tmp/file" git -C "$tmp" add file git -C "$tmp" commit -qm first -git -C "$tmp" tag -m "desktop release" v1.2.3 +git -C "$tmp" tag -m "desktop release" desktop-v1.2.3 ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ) if ( cd "$tmp" - GITHUB_REF=refs/heads/main "$verify" v 1.2.3 + GITHUB_REF=refs/heads/main "$verify" desktop-v 1.2.3 ); then echo "branch-backed desktop release was accepted" >&2 exit 1 @@ -31,7 +31,7 @@ echo second >>"$tmp/file" git -C "$tmp" commit -qam second if ( cd "$tmp" - GITHUB_REF=refs/tags/v1.2.3 "$verify" v 1.2.3 + GITHUB_REF=refs/tags/desktop-v1.2.3 "$verify" desktop-v 1.2.3 ); then echo "release accepted HEAD after the tag commit" >&2 exit 1 @@ -61,6 +61,73 @@ grep -q 'private-key:.*secrets\.BUZZ_RELEASE_TAGGER_PRIVATE_KEY' "$auto_tag" grep -q 'permission-contents: write' "$auto_tag" grep -q 'GH_TOKEN:.*steps\.release-tagger\.outputs\.token' "$auto_tag" grep -Fq 'git/refs' "$auto_tag" +grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" +grep -Fq 'target_sha=${{ github.event.pull_request.head.sha }}' "$auto_tag" +grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" +review_filter="$repo_root/scripts/review-decision-approved.jq" +for fixture in \ + '{"reviewDecision":"CHANGES_REQUESTED"}' \ + '{"reviewDecision":"REVIEW_REQUIRED"}' \ + '{"reviewDecision":null}' \ + '{}'; do + if jq -e -f "$review_filter" <<<"$fixture" >/dev/null; then + echo "review-decision filter accepted non-approved fixture: $fixture" >&2 + exit 1 + fi +done +jq -e -f "$review_filter" >/dev/null <<'JSON' || { +{"reviewDecision":"APPROVED"} +JSON + echo "review-decision filter rejected approved GraphQL response" >&2 + exit 1 +} +required_check_filter="$repo_root/scripts/required-check-succeeded.jq" +check_fixture() { + local expected="$1" conclusion="$2" status="${3:-completed}" + local payload + payload=$(jq -n --arg status "$status" --arg conclusion "$conclusion" '{check_runs: [{name: "Web", status: $status, conclusion: $conclusion, started_at: "2026-01-01T00:00:00Z"}]}') + if jq -e --arg name Web -f "$required_check_filter" <<<"[$payload]" >/dev/null; then + actual=pass + else + actual=fail + fi + [[ "$actual" == "$expected" ]] || { + echo "required-check filter: expected $conclusion/$status to $expected" >&2 + exit 1 + } +} +check_fixture pass success +check_fixture pass skipped +check_fixture pass neutral +check_fixture fail failure +check_fixture fail success in_progress +# A newer failure must not be hidden by an older successful run of the same check. +jq -e --arg name Web -f "$required_check_filter" >/dev/null <<'JSON' && { +[{"check_runs":[ + {"name":"Web","status":"completed","conclusion":"success","started_at":"2026-01-01T00:00:00Z"}, + {"name":"Web","status":"completed","conclusion":"failure","started_at":"2026-01-02T00:00:00Z"} +]}] +JSON + echo "required-check filter accepted a stale pass over a newer failure" >&2 + exit 1 +} +release_workflow="$repo_root/.github/workflows/release.yml" +[[ "$(grep -c 'contents: write' "$release_workflow")" -eq 1 ]] || { + echo "desktop release must have exactly one GitHub contents writer" >&2; exit 1; +} +grep -Fq "needs.release.result == 'success'" "$release_workflow" +grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" +grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" +grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" +grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" +grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" +grep -Fq 'cancel-in-progress: false' "$release_workflow" +grep -Fq 'release artifact basename collision' "$release_workflow" +[[ "$(grep -c 'gh release upload' "$release_workflow")" -eq 2 ]] || { + echo "only the final writer may upload versioned and rolling release assets" >&2; exit 1; +} +grep -Fq 'if: env.already_published' "$release_workflow" grep -Fq 'if gh api "repos/$GITHUB_REPOSITORY/git/ref/tags/$TAG" --silent 2>/dev/null; then' "$auto_tag" if grep -F 'git/ref/tags/$TAG' "$auto_tag" | grep -Fq '|| true'; then echo "auto-tag ignores a failed tag lookup, so a 404 body can look like an existing tag" >&2 diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh new file mode 100755 index 0000000000..17bf2f4dcb --- /dev/null +++ b/scripts/verify-desktop-release-merge.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_HEAD_SHA:?}" +: "${MERGE_SHA:?}" +: "${VERSION:?}" +: "${PR_NUMBER:?}" +: "${GH_TOKEN:?}" + +required_checks=( + "Desktop E2E Integration" + "Desktop" + "Rust Lint" + "Security" + "Unit Tests" + "Windows Rust (x86_64-pc-windows-msvc)" + "Mobile" + "Web" + "Backend Integration (relay e2e)" + "Desktop E2E Relay" + "Relay E2E" + "Desktop Build (macOS)" + "DCO Check" +) + +expected_branch="version-bump/$VERSION" +[[ "${PR_HEAD_REF:-}" == "$expected_branch" ]] || { echo "unexpected release branch" >&2; exit 1; } +[[ "${PR_BASE_REF:-}" == main ]] || { echo "desktop release must target main" >&2; exit 1; } +[[ "${PR_HEAD_REPO:-}" == "$GITHUB_REPOSITORY" ]] || { echo "desktop release must be internal" >&2; exit 1; } + +git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags +mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') +[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } +[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } +git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } + +git checkout --detach "$PR_HEAD_SHA" +scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" + +review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') +jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { + echo "pull request effective review decision is not APPROVED" >&2 + exit 1 +} +reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } + +checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" +for required in "${required_checks[@]}"; do + jq -e --arg name "$required" -f scripts/required-check-succeeded.jq <<<"$checks" >/dev/null || { + echo "required check is missing or unsuccessful: $required" >&2 + exit 1 + } +done +status="$(gh api "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/status")" +jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || { + echo "candidate has a failing or pending combined commit status" >&2 + exit 1 +} + +echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA" From f48f3f055fdd6030d3832f615f8c0d8e5a81261a Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:36:30 +0100 Subject: [PATCH 14/87] Fix video reviews in thread replies (#3719) ## Summary - Show video review comments when a video is opened from a thread reply. - Reuse review-context construction across timeline and thread views. ## Validation - `pnpm run build:e2e && pnpm exec playwright test tests/e2e/video-attachment.spec.ts --project smoke --grep "video replies in threads open the review comments view"` - `pnpm test` --------- Signed-off-by: kenny lopez --- .../src/features/channels/ui/ChannelPane.tsx | 34 +++++----- .../features/channels/ui/ChannelPane.types.ts | 1 + .../features/channels/ui/ChannelScreen.tsx | 2 + .../messages/lib/independentThreadPanel.ts | 16 ++--- .../messages/lib/videoReviewContext.test.mjs | 28 +++++++++ .../messages/lib/videoReviewContext.ts | 45 +++++++++++++ .../messages/ui/MessageThreadPanel.tsx | 11 +++- .../messages/ui/TimelineMessageList.tsx | 47 ++++---------- desktop/tests/e2e/video-attachment.spec.ts | 63 +++++++++++++++++-- 9 files changed, 182 insertions(+), 65 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 92fa172ff5..70877875f7 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -21,10 +21,7 @@ import { getDmHuddleMemberPubkeys, hasOtherDmParticipant, } from "@/features/channels/lib/dmHuddleMembers"; -import { - buildVideoReviewCommentsByRootId, - buildVideoReviewContextForMessage, -} from "@/features/messages/lib/videoReviewContext"; +import { buildVideoReviewContextsByMessageId } from "@/features/messages/lib/videoReviewContext"; import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; import { ChannelFindBar } from "@/features/search/ui/ChannelFindBar"; @@ -150,6 +147,7 @@ export const ChannelPane = React.memo(function ChannelPane({ profilePanelTab, profilePanelView, targetMessageId, + threadAllMessages, threadHeadMessage, threadMessages, threadMessagesPending = false, @@ -472,25 +470,26 @@ export const ChannelPane = React.memo(function ChannelPane({ threadHeadMessage, threadMessages, }); - const videoReviewCommentsByRootId = React.useMemo( - () => buildVideoReviewCommentsByRootId(messages), - [messages], - ); const activeVideoReviewCommentSender = activeChannel?.archivedAt ? undefined : onSendVideoReviewComment; - const threadHeadVideoReviewContext = React.useMemo(() => { - if (!threadHeadMessage) { - return undefined; + const threadVideoReviewContextsByMessageId = React.useMemo(() => { + const messagesById = new Map( + messages.map((message) => [message.id, message]), + ); + if (threadHeadMessage) { + messagesById.set(threadHeadMessage.id, threadHeadMessage); + } + for (const message of threadAllMessages) { + messagesById.set(message.id, message); } - return buildVideoReviewContextForMessage({ + return buildVideoReviewContextsByMessageId({ channelId: activeChannel?.id ?? null, channelName: activeChannel?.name, channelType: activeChannel?.channelType ?? null, - comments: videoReviewCommentsByRootId.get(threadHeadMessage.id) ?? [], isSendingVideoReviewComment: isSending, - message: threadHeadMessage, + messages: [...messagesById.values()], onSendVideoReviewComment: activeVideoReviewCommentSender, onToggleReaction, profiles, @@ -499,10 +498,11 @@ export const ChannelPane = React.memo(function ChannelPane({ activeChannel, activeVideoReviewCommentSender, isSending, + messages, onToggleReaction, profiles, + threadAllMessages, threadHeadMessage, - videoReviewCommentsByRootId, ]); const isOverlay = useIsThreadPanelOverlay(); @@ -876,7 +876,9 @@ export const ChannelPane = React.memo(function ChannelPane({ scrollTargetHighlights={!layoutScrollTargetId} scrollTargetId={layoutScrollTargetId ?? threadScrollTargetId} threadHead={threadHeadMessage} - threadHeadVideoReviewContext={threadHeadVideoReviewContext} + videoReviewContextsByMessageId={ + threadVideoReviewContextsByMessageId + } widthPx={threadPanelWidthPx} threadReplies={threadMessages} threadRepliesPending={threadMessagesPending} diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 5a27d85c4d..7257d8cd55 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -151,6 +151,7 @@ export type ChannelPaneProps = { profilePanelTab: ProfilePanelTab; profilePanelView: ProfilePanelView; threadHeadMessage: TimelineMessage | null; + threadAllMessages: TimelineMessage[]; threadMessages: MainTimelineEntry[]; threadMessagesPending?: boolean; threadPanelWidthPx: number; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 52b3ae7fb7..7b750daa4f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -691,6 +691,7 @@ export function ChannelScreen({ channelManagementOpen, ); const displayedThreadHeadMessage = threadPanelData.threadHead; + const displayedThreadAllMessages = threadPanelData.messages; const displayedThreadMessages = threadPanelData.visibleReplies; const displayedThreadReplyTargetMessage = threadPanelData.replyTargetMessage; const displayedThreadFirstUnreadReplyId = displayedThreadHeadMessage @@ -944,6 +945,7 @@ export function ChannelScreen({ firstUnreadMessageId={firstUnreadMessageId} unreadCount={unreadCount} targetMessageId={mainTimelineTargetMessageId} + threadAllMessages={displayedThreadAllMessages} threadHeadMessage={displayedThreadHeadMessage} threadMessages={displayedThreadMessages} threadMessagesPending={threadRepliesQuery.isPending} diff --git a/desktop/src/features/messages/lib/independentThreadPanel.ts b/desktop/src/features/messages/lib/independentThreadPanel.ts index 4562928d8b..7652c2508c 100644 --- a/desktop/src/features/messages/lib/independentThreadPanel.ts +++ b/desktop/src/features/messages/lib/independentThreadPanel.ts @@ -11,16 +11,18 @@ export function buildIndependentThreadPanel( ...formatArgs: Tail> ) { if (!rootId) { - return buildThreadPanelData([], null, replyTargetId, expandedReplyIds); + return { + ...buildThreadPanelData([], null, replyTargetId, expandedReplyIds), + messages: [], + }; } const head = channelEvents.find((event) => event.id === rootId); const events = head ? [head, ...replyEvents] : replyEvents; - return buildThreadPanelData( - formatTimelineMessages(events, ...formatArgs), - rootId, - replyTargetId, - expandedReplyIds, - ); + const messages = formatTimelineMessages(events, ...formatArgs); + return { + ...buildThreadPanelData(messages, rootId, replyTargetId, expandedReplyIds), + messages, + }; } type Tail = T extends readonly [ diff --git a/desktop/src/features/messages/lib/videoReviewContext.test.mjs b/desktop/src/features/messages/lib/videoReviewContext.test.mjs index de35d53ce3..8ecb5f5798 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.test.mjs +++ b/desktop/src/features/messages/lib/videoReviewContext.test.mjs @@ -5,6 +5,7 @@ import { buildVideoReviewCommentsByRootId, buildVideoReviewCommentsForRoot, buildVideoReviewContextForMessage, + buildVideoReviewContextsByMessageId, hasVideoAttachment, } from "./videoReviewContext.ts"; @@ -209,3 +210,30 @@ test("buildVideoReviewContextForMessage posts against the source video", async ( }, ]); }); + +test("buildVideoReviewContextsByMessageId includes video replies", () => { + const root = message({ id: "root", body: "Review request" }); + const videoReply = message({ + id: "video-reply", + body: "![video](https://relay/media/a.mp4)", + parentId: root.id, + rootId: root.id, + }); + const comment = message({ + id: "comment", + body: "[00:01] tighten this", + parentId: videoReply.id, + rootId: root.id, + }); + + const contexts = buildVideoReviewContextsByMessageId({ + channelId: "channel", + messages: [root, videoReply, comment], + }); + + assert.deepEqual([...contexts.keys()], [videoReply.id]); + assert.deepEqual( + contexts.get(videoReply.id)?.comments.map((item) => item.id), + [comment.id], + ); +}); diff --git a/desktop/src/features/messages/lib/videoReviewContext.ts b/desktop/src/features/messages/lib/videoReviewContext.ts index 8d0798db40..f605952f5a 100644 --- a/desktop/src/features/messages/lib/videoReviewContext.ts +++ b/desktop/src/features/messages/lib/videoReviewContext.ts @@ -148,3 +148,48 @@ export function buildVideoReviewContextForMessage({ rootEventId: message.id, }; } + +export function buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment = false, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, +}: { + channelId?: string | null; + channelName?: string; + channelType?: ChannelType | null; + isSendingVideoReviewComment?: boolean; + messages: TimelineMessage[]; + onSendVideoReviewComment?: SendVideoReviewComment; + onToggleReaction?: ToggleMessageReaction; + profiles?: UserProfileLookup; +}): ReadonlyMap { + const contexts = new Map(); + if (!messages.some(hasVideoAttachment)) { + return contexts; + } + + const commentsByRootId = buildVideoReviewCommentsByRootId(messages); + for (const message of messages) { + const context = buildVideoReviewContextForMessage({ + channelId, + channelName, + channelType, + comments: commentsByRootId.get(message.id) ?? [], + isSendingVideoReviewComment, + message, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); + if (context) { + contexts.set(message.id, context); + } + } + + return contexts; +} diff --git a/desktop/src/features/messages/ui/MessageThreadPanel.tsx b/desktop/src/features/messages/ui/MessageThreadPanel.tsx index 08a57fa4c6..6234af22d1 100644 --- a/desktop/src/features/messages/ui/MessageThreadPanel.tsx +++ b/desktop/src/features/messages/ui/MessageThreadPanel.tsx @@ -108,7 +108,7 @@ type MessageThreadPanelProps = ThreadPanelLayoutProps & { threadUnreadCount?: number; threadReplyUnreadCounts?: ReadonlyMap; threadTypingPubkeys: string[]; - threadHeadVideoReviewContext?: VideoReviewContext; + videoReviewContextsByMessageId?: ReadonlyMap; activityAccessoryContent?: React.ReactNode; activityAccessoryVisible: boolean; widthPx: number; @@ -221,7 +221,7 @@ export function MessageThreadPanel({ scrollTargetId, scrollTargetHighlights = true, threadHead, - threadHeadVideoReviewContext, + videoReviewContextsByMessageId, threadReplies, threadRepliesPending = false, threadUnreadCount, @@ -600,7 +600,9 @@ export function MessageThreadPanel({ } profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} - videoReviewContext={threadHeadVideoReviewContext} + videoReviewContext={videoReviewContextsByMessageId?.get( + threadHead.id, + )} />
@@ -756,6 +758,9 @@ export function MessageThreadPanel({ onToggleReaction={onToggleReaction} profiles={profiles} showDepthGuides={shouldShowThreadBranchGuides} + videoReviewContext={videoReviewContextsByMessageId?.get( + entry.message.id, + )} /> {entry.summary ? ( - messages.some(hasVideoAttachment) - ? buildVideoReviewCommentsByRootId(messages) - : new Map(), - [messages], - ); // Contexts are memoized per message id so MessageRow/Markdown memo // comparisons hold across unrelated timeline re-renders (typing // indicators, presence updates) — a fresh context object per render would // defeat the memo and re-render every video message on every pass. const videoReviewContextById = React.useMemo(() => { - const contexts = new Map< - string, - NonNullable> - >(); - for (const message of messages) { - const comments = reviewCommentsByRootId.get(message.id) ?? []; - const context = buildVideoReviewContextForMessage({ - channelId, - channelName, - channelType, - comments, - isSendingVideoReviewComment, - message, - onSendVideoReviewComment, - onToggleReaction, - profiles, - }); - if (context) { - contexts.set(message.id, context); - } - } - return contexts; + return buildVideoReviewContextsByMessageId({ + channelId, + channelName, + channelType, + isSendingVideoReviewComment, + messages, + onSendVideoReviewComment, + onToggleReaction, + profiles, + }); }, [ channelId, channelName, @@ -213,7 +191,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ onSendVideoReviewComment, onToggleReaction, profiles, - reviewCommentsByRootId, ]); // The flattened item stream, memoized on the entries and the unread boundary diff --git a/desktop/tests/e2e/video-attachment.spec.ts b/desktop/tests/e2e/video-attachment.spec.ts index 8f4bb0aa4a..458689a5e0 100644 --- a/desktop/tests/e2e/video-attachment.spec.ts +++ b/desktop/tests/e2e/video-attachment.spec.ts @@ -53,25 +53,31 @@ function emitMockMessage( page: Page, channelName: string, content: string, - options: { extraTags?: string[][] } = {}, + options: { extraTags?: string[][]; parentEventId?: string } = {}, ) { return page.evaluate( - ({ channelName, content, extraTags }) => { + ({ channelName, content, extraTags, parentEventId }) => { const emit = ( window as Window & { __BUZZ_E2E_EMIT_MOCK_MESSAGE__?: (input: { channelName: string; content: string; extraTags?: string[][]; + parentEventId?: string; }) => unknown; } ).__BUZZ_E2E_EMIT_MOCK_MESSAGE__; if (!emit) { throw new Error("Mock message emitter is unavailable."); } - emit({ channelName, content, extraTags }); + return emit({ channelName, content, extraTags, parentEventId }); + }, + { + channelName, + content, + extraTags: options.extraTags, + parentEventId: options.parentEventId, }, - { channelName, content, extraTags: options.extraTags }, ); } @@ -765,6 +771,55 @@ test("video upload previews use poster frames and inline videos open review mode ).toContainText("Color pass looks right"); }); +test("video replies in threads open the review comments view", async ({ + page, +}) => { + await installVideoReviewHarness(page); + + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await waitForMockLiveSubscription(page, "general"); + + const root = (await emitMockMessage( + page, + "general", + "Can you review this cut?", + )) as { id: string }; + const videoReply = (await emitMockMessage( + page, + "general", + `![video](${VIDEO_URL})`, + { + parentEventId: root.id, + }, + )) as { id: string }; + await emitMockMessage(page, "general", "[00:01] Tighten this transition.", { + parentEventId: videoReply.id, + }); + + const threadSummary = page.locator(`[data-thread-head-id="${root.id}"]`); + await expect(threadSummary).toBeVisible(); + await threadSummary.click(); + + const threadPanel = page.getByTestId("message-thread-panel"); + const threadReplies = threadPanel.getByTestId("message-thread-replies"); + const reviewButton = threadReplies.getByRole("button", { + name: "Open video review", + }); + await expect(reviewButton).toBeVisible(); + await reviewButton.click(); + + const reviewDialog = page.getByTestId("video-review-dialog"); + await expect( + reviewDialog.getByTestId("video-review-comments-panel"), + ).toBeVisible(); + await expect(reviewDialog.getByTestId("message-composer")).toBeVisible(); + await expect(reviewDialog.getByTestId("video-review-comments")).toContainText( + "Tighten this transition.", + ); +}); + test("narrow inline videos hide playback speed control", async ({ page }) => { await installVideoReviewHarness(page); From 6e419b9f1c873549a7b40996970e0da7352adafb Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Thu, 30 Jul 2026 19:39:12 +0100 Subject: [PATCH 15/87] Tighten continuation message rows (#3724) ## Summary - use uniform 4px top and bottom padding for continuation rows - keep continuation timestamps top-aligned and remove the thread-only minimum-height gutter - raise continuation hover actions by 12px - align virtualized row estimates with the compact layout ## Validation - `pnpm test` (3,782 tests via pre-push) - `pnpm check` - desktop snapshots ## Screenshots ### Mention-chip continuation ![Mention-chip continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--thread-continuation.png) ### Emoji continuation ![Emoji continuation](https://raw.githubusercontent.com/block/buzz/85b88763ef8147f3376c9bf794bc0973a0211a57/pr-3724--channel-continuation.png) --------- Signed-off-by: kenny lopez --- .../src/features/messages/lib/rowHeightEstimate.test.mjs | 7 +++++++ desktop/src/features/messages/lib/rowHeightEstimate.ts | 4 ++-- desktop/src/features/messages/ui/MessageRow.tsx | 8 +++++--- desktop/tests/e2e/messaging.spec.ts | 4 +++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs index dc33da4f8f..f17a53c661 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs +++ b/desktop/src/features/messages/lib/rowHeightEstimate.test.mjs @@ -23,6 +23,13 @@ test("estimateRowHeight: short text is near the floor", () => { assert.ok(h >= 60 && h < 120, `expected small, got ${h}`); }); +test("estimateRowHeight: continuation reserves its uniform padding", () => { + const h = estimateRowHeight(msg({ body: "hello" }), { + isContinuation: true, + }); + assert.equal(h, 28); +}); + test("estimateRowHeight: many lines reserve more", () => { const tall = estimateRowHeight( msg({ body: Array.from({ length: 20 }, (_, i) => `line ${i}`).join("\n") }), diff --git a/desktop/src/features/messages/lib/rowHeightEstimate.ts b/desktop/src/features/messages/lib/rowHeightEstimate.ts index f2fb268167..acefae95d4 100644 --- a/desktop/src/features/messages/lib/rowHeightEstimate.ts +++ b/desktop/src/features/messages/lib/rowHeightEstimate.ts @@ -26,13 +26,13 @@ const TEXT_LINE_HEIGHT = 20; const CODE_LINE_HEIGHT = 19; const CHARS_PER_LINE = 64; // rough wrap width at the timeline column const ROW_CHROME = 26; // author/time header + denser row padding -const CONTINUATION_ROW_CHROME = 8; // dense row padding only; header/avatar are hidden +const CONTINUATION_ROW_CHROME = 8; // uniform py-1 padding; header/avatar are hidden const MEDIA_BLOCK_MARGIN_TOP = 4; // image/video blocks use mt-1 in markdown const REACTION_ROW = 24; const PREVIEW_CARD = 70; const MESSAGE_ITEM_BOTTOM_PADDING = 10; // TimelineMessageList pb-2.5 const MIN_ESTIMATE = 60; // never reserve less than the old flat floor -const CONTINUATION_MIN_ESTIMATE = 34; +const CONTINUATION_MIN_ESTIMATE = 28; function mediaHeightFromDim(dim: string | undefined): number { const dimensions = dimensionsFromDim(dim); diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 069232fde4..688b5d5f0d 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -433,8 +433,8 @@ export const MessageRow = React.memo( ) : null} + {install.isPending && installOutputLine ? ( +

+ {installOutputLine} +

+ ) : null} + {installError ? (

{installError} diff --git a/desktop/src/features/settings/ui/HarnessRow.tsx b/desktop/src/features/settings/ui/HarnessRow.tsx index de6666b8c3..c0feef13cc 100644 --- a/desktop/src/features/settings/ui/HarnessRow.tsx +++ b/desktop/src/features/settings/ui/HarnessRow.tsx @@ -10,6 +10,7 @@ import { useManagedAgentsQuery, usePersonasQuery, } from "@/features/agents/hooks"; +import { useInstallOutputLine } from "@/features/agents/lib/useInstallOutputLine"; import { RuntimeIcon } from "@/features/onboarding/ui/RuntimeIcon"; import type { AcpAuthMethod, AcpRuntimeCatalogEntry } from "@/shared/api/types"; import { getInstallErrorMessage } from "@/shared/lib/installError"; @@ -324,6 +325,7 @@ export function HarnessRow({ }, [resetEpoch]); const isInstalling = installMutation.isPending; const installError = installResult?.error ?? null; + const installOutputLine = useInstallOutputLine(runtime.id, isInstalling); const del = useDeleteCustomHarnessMutation(); // Blast-radius data for the delete confirmation — only fetched while the @@ -348,7 +350,7 @@ export function HarnessRow({ } else { setInstallResult({ success: false, - error: getInstallErrorMessage(result.steps), + error: getInstallErrorMessage(result), }); } }, @@ -479,6 +481,15 @@ export function HarnessRow({

) : null} + {isInstalling && installOutputLine ? ( +

+ {installOutputLine} +

+ ) : null} {installError ? (

({ + step: step.step, + command: step.command, + success: step.success, + stdout: step.stdout, + stderr: step.stderr, + exitCode: step.exit_code, + hint: step.hint, + })), + restartedCount: raw.restarted_count, + failedRestartCount: raw.failed_restart_count, + logPath: raw.log_path ?? null, + }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index c57525480e..69e2e455ec 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -3,6 +3,10 @@ import { activateRateLimit, parseRateLimitHint, } from "@/shared/api/relayRateLimitGate"; +import { + fromRawInstallRuntimeResult, + type RawInstallRuntimeResult, +} from "@/shared/api/installTypes"; import type { AddChannelMembersInput, AddChannelMembersResult, @@ -202,22 +206,10 @@ export type RawAcpRuntimeCatalogEntry = { definition_env?: Record; }; -export type RawInstallStepResult = { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; -}; - -export type RawInstallRuntimeResult = { - success: boolean; - steps: RawInstallStepResult[]; - restarted_count: number; - failed_restart_count: number; -}; +export type { + RawInstallRuntimeResult, + RawInstallStepResult, +} from "./installTypes"; type RawGitBashPrerequisite = { available: boolean; @@ -772,25 +764,6 @@ export function fromRawAcpRuntimeCatalogEntry( }; } -function fromRawInstallRuntimeResult( - raw: RawInstallRuntimeResult, -): InstallRuntimeResult { - return { - success: raw.success, - steps: raw.steps.map((step) => ({ - step: step.step, - command: step.command, - success: step.success, - stdout: step.stdout, - stderr: step.stderr, - exitCode: step.exit_code, - hint: step.hint, - })), - restartedCount: raw.restarted_count, - failedRestartCount: raw.failed_restart_count, - }; -} - function fromRawCommandAvailability( command: RawCommandAvailability, ): CommandAvailability { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 689c400b03..877b5b1c61 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -571,22 +571,10 @@ export type AcpRuntime = AcpRuntimeCatalogEntry & { binaryPath: string; }; -export type InstallStepResult = { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exitCode: number | null; - hint?: string; -}; - -export type InstallRuntimeResult = { - success: boolean; - steps: InstallStepResult[]; - restartedCount: number; - failedRestartCount: number; -}; +export type { + InstallRuntimeResult, + InstallStepResult, +} from "./installTypes"; export type AcpAuthMethod = { id: string; diff --git a/desktop/src/shared/lib/configNudge.ts b/desktop/src/shared/lib/configNudge.ts index 82ed8f306a..86c0e16c13 100644 --- a/desktop/src/shared/lib/configNudge.ts +++ b/desktop/src/shared/lib/configNudge.ts @@ -32,7 +32,7 @@ export type ConfigNudgeRequirement = * Determines which message and CTA the nudge card shows: * - "available" → tooling installed, needs login * - "adapter_missing" → CLI installed but ACP adapter missing - * - "adapter_outdated" → ACP adapter present but from deprecated package; reinstall required + * - "adapter_outdated" → ACP adapter present but unsupported/outdated; reinstall required * - "cli_missing" → ACP adapter installed but CLI missing * - "not_installed" → neither adapter nor CLI found */ diff --git a/desktop/src/shared/lib/installError.test.mjs b/desktop/src/shared/lib/installError.test.mjs index c6b51186a8..181d7b802b 100644 --- a/desktop/src/shared/lib/installError.test.mjs +++ b/desktop/src/shared/lib/installError.test.mjs @@ -3,84 +3,108 @@ import test from "node:test"; import { getInstallErrorMessage } from "./installError.ts"; +/** A failed install result carrying `steps` and, optionally, a log pointer. */ +function failed(steps, logPath = null) { + return { + success: false, + steps, + restartedCount: 0, + failedRestartCount: 0, + logPath, + }; +} + test("getInstallErrorMessage: empty steps array returns fallback", () => { - assert.equal(getInstallErrorMessage([]), "Install failed with no output."); + assert.equal( + getInstallErrorMessage(failed([])), + "Install failed with no output.", + ); }); test("getInstallErrorMessage: failed step without hint contains step name and stderr", () => { - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied", + exitCode: 1, + }, + ]), + ); assert.match(message, /Step "adapter" failed:/); assert.match(message, /EACCES: permission denied/); }); test("getInstallErrorMessage: failed step without hint does not contain hint-ish text", () => { - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied", + exitCode: 1, + }, + ]), + ); assert.doesNotMatch(message, /npm config set prefix/); }); test("getInstallErrorMessage: failed step with hint starts with hint and still contains stderr", () => { const hint = "Fix the npm prefix ownership:\n sudo chown -R $USER $(npm config get prefix)"; - const message = getInstallErrorMessage([ - { - step: "adapter", - command: "npm install -g @block/buzz-acp", - success: false, - stdout: "", - stderr: "EACCES: permission denied, mkdir '/usr/local/lib'", - exitCode: 1, - hint, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "adapter", + command: "npm install -g @block/buzz-acp", + success: false, + stdout: "", + stderr: "EACCES: permission denied, mkdir '/usr/local/lib'", + exitCode: 1, + hint, + }, + ]), + ); assert.ok(message.startsWith(hint), "message should start with hint"); assert.match(message, /EACCES: permission denied/); }); test("getInstallErrorMessage: failed step with empty stderr falls back to stdout", () => { - const message = getInstallErrorMessage([ - { - step: "node", - command: "node --version", - success: false, - stdout: "some stdout output", - stderr: "", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "node", + command: "node --version", + success: false, + stdout: "some stdout output", + stderr: "", + exitCode: 1, + }, + ]), + ); assert.match(message, /some stdout output/); }); test("getInstallErrorMessage: hint and step detail are separated by double newline for whitespace-pre-line rendering", () => { const hint = "Git Bash is required. Install it from git-scm.com."; - const message = getInstallErrorMessage([ - { - step: "shell", - command: "bash -l -c 'npm install'", - success: false, - stdout: "", - stderr: "bash: command not found", - exitCode: 127, - hint, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "shell", + command: "bash -l -c 'npm install'", + success: false, + stdout: "", + stderr: "bash: command not found", + exitCode: 127, + hint, + }, + ]), + ); assert.ok( message.includes("\n\n"), "hint and step detail should be separated by a blank line", @@ -89,25 +113,72 @@ test("getInstallErrorMessage: hint and step detail are separated by double newli }); test("getInstallErrorMessage: only reports the last (failing) step when multiple steps present", () => { - const message = getInstallErrorMessage([ - { - step: "node", - command: "node --version", - success: true, - stdout: "v20.0.0", - stderr: "", - exitCode: 0, - }, - { - step: "adapter", - command: "npm install -g @agentclientprotocol/claude-code-acp", - success: false, - stdout: "", - stderr: "npm ERR! code E404", - exitCode: 1, - }, - ]); + const message = getInstallErrorMessage( + failed([ + { + step: "node", + command: "node --version", + success: true, + stdout: "v20.0.0", + stderr: "", + exitCode: 0, + }, + { + step: "adapter", + command: "npm install -g @agentclientprotocol/claude-code-acp", + success: false, + stdout: "", + stderr: "npm ERR! code E404", + exitCode: 1, + }, + ]), + ); assert.match(message, /Step "adapter" failed:/); assert.match(message, /npm ERR! code E404/); assert.doesNotMatch(message, /Step "node"/); }); + +test("getInstallErrorMessage: points at the install log when one was written", () => { + const message = getInstallErrorMessage( + failed( + [ + { + step: "cli", + command: "curl … | bash", + success: false, + stdout: "", + stderr: "download failed", + exitCode: 1, + }, + ], + "/logs/install-goose.log", + ), + ); + assert.match(message, /download failed/); + assert.ok( + message.endsWith("\n\nFull log: /logs/install-goose.log"), + `log pointer should close the message, got: ${message}`, + ); +}); + +test("getInstallErrorMessage: omits the log pointer when no log was written", () => { + const message = getInstallErrorMessage( + failed([ + { + step: "cli", + command: "curl … | bash", + success: false, + stdout: "", + stderr: "download failed", + exitCode: 1, + }, + ]), + ); + assert.doesNotMatch(message, /Full log/); +}); + +test("getInstallErrorMessage: a run with no steps at all still points at its log", () => { + const message = getInstallErrorMessage(failed([], "/logs/install-goose.log")); + assert.match(message, /Install failed with no output\./); + assert.match(message, /Full log: \/logs\/install-goose\.log/); +}); diff --git a/desktop/src/shared/lib/installError.ts b/desktop/src/shared/lib/installError.ts index bf72c4d3b2..82bcd4a310 100644 --- a/desktop/src/shared/lib/installError.ts +++ b/desktop/src/shared/lib/installError.ts @@ -1,15 +1,25 @@ -import type { InstallStepResult } from "@/shared/api/types"; +import type { InstallRuntimeResult } from "@/shared/api/types"; /** * Build the user-visible error message for a failed install. * When the last step carries an actionable hint, it is shown first, * followed by the raw step failure detail. + * + * The step detail is truncated for display, so the message ends with a pointer + * to the install log — which holds every attempt of every step, each record + * bounded far above the display truncation — when one was written. */ -export function getInstallErrorMessage(steps: InstallStepResult[]): string { +export function getInstallErrorMessage(result: InstallRuntimeResult): string { + const { steps, logPath } = result; const lastStep = steps[steps.length - 1]; if (!lastStep) { - return "Install failed with no output."; + return withLog("Install failed with no output.", logPath); } const base = `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}`; - return lastStep.hint ? `${lastStep.hint}\n\n${base}` : base; + const detail = lastStep.hint ? `${lastStep.hint}\n\n${base}` : base; + return withLog(detail, logPath); +} + +function withLog(message: string, logPath: string | null): string { + return logPath ? `${message}\n\nFull log: ${logPath}` : message; } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 07eaa77902..841e6ba83f 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,4 +1,5 @@ import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { emit } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; import { decode, npubEncode } from "nostr-tools/nip19"; import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; @@ -203,6 +204,8 @@ type E2eConfig = { acpRuntimesCatalogAfterConnect?: RawAcpRuntimeCatalogEntry[]; activePersonaIds?: string[]; installAcpRuntimeDelayMs?: number; + /** Live output lines the mocked install emits before it settles. */ + installAcpRuntimeOutputLines?: string[]; installAcpRuntimeResult?: RawInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. * Call N returns results[N]; when exhausted the last entry repeats. @@ -1257,6 +1260,8 @@ const REACTION_TARGET_CONTENT = "React to me with a custom emoji"; // REACTION_TARGET_EVENT_ID. const SYSTEM_REACTION_TARGET_EVENT_ID = "e".repeat(64); const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "buzz:e2e-identity-override.v1"; +/** Stands in for `tauri.conf.json`'s version, which no mock IPC call can read. */ +const MOCK_APP_VERSION = "0.0.0-e2e"; const DEFAULT_MOCK_IDENTITY = { pubkey: "deadbeef".repeat(8), display_name: "npub1mock...", @@ -7228,6 +7233,53 @@ let personaSharePublicationCallCount = 0; // Per-page confirm_team_snapshot_import call counter for sequenced error testing. let teamSnapshotConfirmCallCount = 0; +// Live-output sequence for the install currently being replayed. The backend +// counter is per run (`InstallReporter::for_run` starts a fresh one), so this +// restarts too — a bridge that stayed monotonic across installs would hide a UI +// that carried a stale sequence number into the next run and rejected all of it. +let installOutputSeq = 0; + +/** + * Replay the live output the Rust reporter emits while an install runs: a clear + * signal, then one line per entry. `seq` is install-wide and monotonic, matching + * the backend contract the UI's ordering depends on. + * + * The clear and the first line emit synchronously with the install invocation, + * exactly as the backend does — the command is invoked from the click handler, + * so those events land before React has committed the pending install state. + * Delaying them would let a listener that mounts on that state still catch them, + * hiding the very race the UI has to survive. + * + * Later lines are spaced so each is observable rather than collapsing into one + * frame with the next. + */ +const INSTALL_OUTPUT_REPLAY_GAP_MS = 1000; + +async function replayInstallOutput( + runtimeId: string, + lines: string[], +): Promise { + installOutputSeq = 0; + // The leading null is the clear signal the backend sends when an attempt + // starts, so this replays a whole attempt rather than only its output. + const events: (string | null)[] = [null, ...lines]; + for (const [index, line] of events.entries()) { + // Index 0 and 1 are the clear and the first line: no gap before either. + if (index > 1) { + await new Promise((resolve) => + window.setTimeout(resolve, INSTALL_OUTPUT_REPLAY_GAP_MS), + ); + } + // `emit` reaches listeners registered through the real `listen` API, which + // is what the UI hook uses; mockIPC's shouldMockEvents wires the two. + await emit("acp-install-output", { + runtime_id: runtimeId, + seq: installOutputSeq++, + line, + }); + } +} + async function handleInstallAcpRuntime( args: { runtimeId?: string; @@ -7235,6 +7287,10 @@ async function handleInstallAcpRuntime( config: E2eConfig | undefined, ): Promise { const runtimeId = args.runtimeId ?? ""; + const outputLines = config?.mock?.installAcpRuntimeOutputLines; + if (outputLines && outputLines.length > 0) { + await replayInstallOutput(runtimeId, outputLines); + } const perRuntime = config?.mock?.installAcpRuntimeByRuntime?.[runtimeId]; if (perRuntime) { @@ -7291,6 +7347,7 @@ async function handleInstallAcpRuntime( ], restarted_count: 0, failed_restart_count: 0, + log_path: null, }; } @@ -11629,6 +11686,11 @@ export function maybeInstallE2eTauriMocks() { return null; case "plugin:window|is_fullscreen": return false; + // Settings reads the app version through the app plugin. Without this the + // bridge throws an unhandled page error on every Settings render, which + // shows up as noise in unrelated specs. + case "plugin:app|version": + return MOCK_APP_VERSION; case "merge_save_subscription_kinds": { // Mirrors `merge_owner_p_kinds`: union `kind` into the owner_p row's // kinds, creating the row if it doesn't exist yet. diff --git a/desktop/tests/e2e/doctor-states.spec.ts b/desktop/tests/e2e/doctor-states.spec.ts index 77d0fbcb45..2d69a4e0da 100644 --- a/desktop/tests/e2e/doctor-states.spec.ts +++ b/desktop/tests/e2e/doctor-states.spec.ts @@ -983,4 +983,91 @@ test.describe("Doctor panel state screenshots", () => { path: `${SHOTS}/08-concurrent-installs-and-stale-clear.png`, }); }); + /** + * 09 — install observability: the live output line appears while the install + * runs and disappears when it settles, and the failure message points at the + * install log rather than only the truncated last step. + */ + test("09-install-output-line-and-log-pointer", async ({ page }) => { + await installMockBridge(page, { + acpRuntimesCatalog: [ + GOOSE_AVAILABLE, + CLAUDE_AVAILABLE_LOGGED_IN, + { + ...CODEX_NOT_INSTALLED, + can_auto_install: true, + node_required: false, + }, + BUZZ_AGENT_AVAILABLE, + ], + installAcpRuntimeDelayMs: 500, + installAcpRuntimeOutputLines: [ + "npm http fetch GET 200 @zed-industries/codex-acp", + "npm warn deprecated a transitive dependency", + ], + installAcpRuntimeResult: { + success: false, + steps: [ + { + step: "adapter", + command: "npm install -g @zed-industries/codex-acp", + success: false, + stdout: "", + stderr: "npm ERR! code E404", + exit_code: 1, + }, + ], + log_path: "/tmp/buzz-install-codex.log", + }, + }); + + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "agents"); + + const row = page.getByTestId("doctor-runtime-codex"); + await expect(row).toBeVisible({ timeout: 10_000 }); + + const installButton = page.getByTestId("doctor-runtime-install-codex"); + await expect(installButton).toBeEnabled(); + await installButton.click(); + + // The bridge emits the attempt-start clear and the first line synchronously + // with the install invocation — before React commits the pending state — so + // observing this line proves the listener was already mounted at the click. + // A subscription that waited for the install state would have missed both. + const outputLine = page.getByTestId("doctor-runtime-install-output-codex"); + await expect(outputLine).toContainText("npm http fetch", { + timeout: 5_000, + }); + + // Each new line replaces the previous one rather than accumulating. + await expect(outputLine).toContainText("npm warn deprecated", { + timeout: 5_000, + }); + await expect(outputLine).not.toContainText("npm http fetch"); + + // Settled: the line clears, so a finished install leaves no stale output + // under a fresh Install button. + const installError = page.getByTestId("doctor-runtime-install-error-codex"); + await expect(installError).toBeVisible({ timeout: 5_000 }); + await expect(outputLine).toHaveCount(0); + + // The failure points at the log holding bounded output for every attempt. + await expect(installError).toContainText("npm ERR! code E404"); + await expect(installError).toContainText("/tmp/buzz-install-codex.log"); + + await row.scrollIntoViewIfNeeded(); + await waitForAnimations(page); + await row.screenshot({ + path: `${SHOTS}/09-install-output-line-and-log-pointer.png`, + }); + + // A second install shows its own output. The backend sequence restarts per + // run, so a display that kept the previous run's sequence number would + // reject every event of this one and show nothing at all. + await installButton.click(); + await expect(outputLine).toContainText("npm http fetch", { + timeout: 5_000, + }); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 468f860203..c3473ae4f1 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -131,6 +131,22 @@ export type MockAgentMemoryListing = { fetchedAt: number; }; +/** Result returned by the `install_acp_runtime` mock command. */ +type MockInstallRuntimeResult = { + success: boolean; + steps: { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; + hint?: string; + }[]; + /** Install log the failure message points at. Omitted = no log was written. */ + log_path?: string | null; +}; + type MockBridgeOptions = { /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; @@ -171,35 +187,17 @@ type MockBridgeOptions = { connectAcpRuntimeDelayMs?: number; connectAcpRuntimeError?: string; installAcpRuntimeDelayMs?: number; + /** Live output lines the mocked install emits before it settles, in order. + * Each arrives as an `acp-install-output` event, preceded by the clear + * signal the backend sends at the start of an attempt. */ + installAcpRuntimeOutputLines?: string[]; /** Override the result returned by the `install_acp_runtime` mock command. * Pass `{ success: false, steps: [...] }` to exercise error/Retry states. */ - installAcpRuntimeResult?: { - success: boolean; - steps: { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; - }[]; - }; + installAcpRuntimeResult?: MockInstallRuntimeResult; /** Sequence of results for successive `install_acp_runtime` calls. Call N * returns results[N]; when exhausted the last entry repeats. Takes precedence * over `installAcpRuntimeResult`. Use for fail-then-succeed Retry tests. */ - installAcpRuntimeResults?: Array<{ - success: boolean; - steps: { - step: string; - command: string; - success: boolean; - stdout: string; - stderr: string; - exit_code: number | null; - hint?: string; - }[]; - }>; + installAcpRuntimeResults?: MockInstallRuntimeResult[]; activePersonaIds?: string[]; /** * Listing returned by the mocked `get_agent_memory` command. Pass a single From b9e4ed616f39b812bc964e79c7a40223c4e93832 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 15:50:51 -0600 Subject: [PATCH 21/87] test(desktop): click visible thread collapse guide (#3800) ## Summary - target the visible thread branch collapse guide in the messaging smoke test - avoid clicking the underlying collapse rail when the guide overlaps it - retain the existing post-click assertions that verify the two-reply branch collapses ## Context `main` CI failed because Playwright repeatedly attempted to click the lower `thread-collapse-rail` while the matching `thread-collapse-guide` intercepted pointer events. Both controls dispatch collapse for the same branch; the guide is the actual topmost user target and is already used by `thread-unread.spec.ts`. Failing run: https://github.com/block/buzz/actions/runs/30575425126 ## Validation - focused Playwright smoke test: 1 passed - pre-push hooks: desktop check passed; 3,835 desktop tests passed - `git diff --check` ## Review Princess Donut reviewed the test-only approach and locator determinism with no blockers. Mongo review is pending. Signed-off-by: Wes Co-authored-by: Carl --- desktop/tests/e2e/messaging.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/desktop/tests/e2e/messaging.spec.ts b/desktop/tests/e2e/messaging.spec.ts index 43a617ffd4..c6f5aefb9b 100644 --- a/desktop/tests/e2e/messaging.spec.ts +++ b/desktop/tests/e2e/messaging.spec.ts @@ -918,10 +918,10 @@ test("opens a single-level thread panel with inline expansion", async ({ `[data-testid="message-thread-summary"][data-thread-head-id="${firstReplyId}"]`, ); await expect(firstReplySummaryRow).toHaveCount(0); - const firstReplyBranchRail = threadReplies.locator( - `[data-testid="thread-collapse-rail"][data-thread-head-id="${firstReplyId}"]`, + const firstReplyBranchGuide = threadReplies.locator( + `[data-testid="thread-collapse-guide"][data-thread-head-id="${firstReplyId}"]`, ); - await expect(firstReplyBranchRail).toHaveCount(1); + await expect(firstReplyBranchGuide).not.toHaveCount(0); await expect(rootSummaryRow).toContainText("18 replies"); await expect( @@ -941,7 +941,7 @@ test("opens a single-level thread panel with inline expansion", async ({ await expectThreadReplyUnobscured(nestedReplyRow); - await firstReplyBranchRail.click(); + await firstReplyBranchGuide.first().click(); await expect(firstReplySummaryRow).toHaveCount(1); await expect(firstReplySummaryRow).toContainText("2 replies"); await expect( From 114d40d9d37f05eff83ee90347ed93fb3da512c5 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 17:53:30 -0400 Subject: [PATCH 22/87] feat(relay): gate kind 30178 team-catalog reads behind the shared tag (#3358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Team catalog projections (`kind:30178`) embed every member's system prompt, so they need the same read gate personas already have: only the author sees an unshared event. The gate was hardcoded to `kind:30175` at six read surfaces plus the SQL pushdown, so rather than adding a second special case it becomes kind-generic over `SHARED_GATED_KINDS = {30175, 30178}`. ## Kind 30178 New parameterized-replaceable kind, addressed by `(pubkey_o, 30178, team_id)`. It embeds sanitized member projections instead of referencing `kind:30175` heads — a foreign reader of a shared team could not otherwise hydrate members whose own persona events are unshared or, for built-ins, absent entirely. `kind:30176`'s wire body is untouched, so device sync keeps its contract. ## Kind-generic shared gate `buzz_core::kind` replaces `is_persona_shared_kind` / `is_unshared_persona_event` / `persona_event_is_shared` with `SHARED_GATED_KINDS` and the kind-agnostic `is_shared_gated_kind` / `is_unshared_gated_event` / `event_is_shared`. Every read surface consults the set: | Surface | File | |---|---| | REQ historical delivery + `ids` lookup | `crates/buzz-relay/src/handlers/req.rs` | | Live fan-out | `crates/buzz-relay/src/handlers/event.rs` | | COUNT fallback | `crates/buzz-relay/src/handlers/count.rs` | | NIP-98 HTTP `/query`, `/count`, `/search` | `crates/buzz-relay/src/api/bridge.rs` | | Pre-`LIMIT` SQL pushdown | `crates/buzz-db/src/event.rs` | The SQL clause generalizes from `kind != 30175` to `kind NOT IN (...)` bound from `SHARED_GATED_KINDS`, still applied before `ORDER BY … LIMIT` so a page of newer private events cannot starve an older shared one off the candidate set. `EventQuery::persona_reader` is renamed `shared_gated_reader` and `needs_persona_filtering` to `needs_shared_gate_filtering` to match. Because the `buzz-core` rename has consumers outside the relay, the four desktop call sites of `persona_event_is_shared` travel with it: `desktop/src-tauri/src/commands/personas/pending.rs`, `desktop/src-tauri/src/event_sync.rs`, and two in `desktop/src-tauri/src/managed_agents/persona_events.rs`. Each call is unchanged apart from the name — the persona `shared` projection behaves exactly as before. ## Ingest validation `validate_persona_envelope` splits into two reusable pieces — `validate_shared_tag` (exactly-two-element `["shared","true"]`, at most one occurrence) and `single_bounded_d_tag` (exactly one `d` tag, non-empty, `<=64` chars, no ASCII control characters or whitespace). `validate_team_catalog_envelope` composes both; personas additionally keep the slug grammar `^[a-z0-9][a-z0-9_-]{0,63}$`. `kind:30178` deliberately does **not** get the slug grammar. Team ids are UUIDs or built-in identifiers such as `builtin-team:welcome`, and the colon is not slug-legal; rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head. The non-empty and exactly-one checks are load-bearing regardless — without them generic NIP-33 storage maps a missing `d` onto `(pubkey_o, 30178, "")` and every team overwrites its predecessor. The exact two-element `shared` shape is enforced because the SQL visibility clause is JSONB containment (`tags @> '[["shared","true"]]'`), which would match a three-element superset such as `["shared","true","extra"]`. `kind:30178` is also added to the `Scope::UsersWrite` allowlist and to `is_global_only_kind`, so a stray `h` tag cannot channel-scope an owner-authored definition. ## Deferred `kind:30176` is deliberately not a gate member. Its writers never emit `shared`, so catalog opt-in semantics do not describe it — it needs owner-private reads driven by an authenticated principal set, tracked as a separate follow-up. ## Tests - 19 new `ingest.rs` unit tests covering the 30178 envelope (UUID and colon `d` tags, 64-char boundary, non-ASCII bound, empty/valueless/duplicate/missing `d`, embedded newline, `shared` false/three-element/duplicate, scope and global-only membership). - Persona regressions for the valueless `["d"]` shapes, since the `d`-tag helper is shared by both validators. - Existing `kind.rs` gate tests generalized and extended to assert the gate applies to 30178 as it does to 30175. - New `crates/buzz-test-client/tests/e2e_team_catalog.rs`: 9 WS-level tests over a live relay covering author reads of unshared heads, foreign omission from REQ, `ids`-lookup denial, COUNT existence-leak, share and unshare transitions, and the mixed-kind filter case. - `.github/workflows/ci.yml` adds `--test e2e_team_catalog` to the Relay E2E job so the new suite runs. ## Docs `docs/nips/NIP-AP.md` gains a "Team catalog projection: kind:30178" section and an "Ingest validation: kind:30178" subsection, records the gate as kind-generic, documents 30178 deletion vs. unshare semantics, and adds a security note that sharing a team exposes every member's instructions even when that member's own `kind:30175` head is unshared. Signed-off-by: Will Pfleger --- .github/workflows/ci.yml | 2 +- crates/buzz-core/src/kind.rs | 182 +++++-- crates/buzz-db/src/event.rs | 49 +- crates/buzz-relay/src/api/bridge.rs | 38 +- crates/buzz-relay/src/handlers/count.rs | 32 +- crates/buzz-relay/src/handlers/event.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 309 +++++++++-- crates/buzz-relay/src/handlers/req.rs | 42 +- crates/buzz-test-client/tests/e2e_persona.rs | 4 +- .../tests/e2e_team_catalog.rs | 484 ++++++++++++++++++ .../src/commands/personas/pending.rs | 4 +- desktop/src-tauri/src/event_sync.rs | 2 +- .../src/managed_agents/persona_events.rs | 4 +- docs/nips/NIP-AP.md | 48 +- 14 files changed, 1033 insertions(+), 179 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_team_catalog.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59d63f28da..bc594e16ad 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -739,7 +739,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index afec52305a..e5f67f671f 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -182,29 +182,43 @@ pub const P_GATED_KINDS: &[u32] = &[ /// or more than one `shared` tag) so no ambiguous heads can exist. pub const KIND_PERSONA: u32 = 30175; -/// Returns `true` if `kind` uses the author-only-unless-shared read model -/// (currently only `KIND_PERSONA` / 30175). +/// Kinds that use the author-only-unless-shared read model. /// /// Events of these kinds may only be delivered to foreign readers when the -/// event carries exactly `["shared", "true"]`. Used by all relay read -/// chokepoints: REQ historical delivery, live fan-out, COUNT fallback, -/// and the `ids`-lookup result gate. -pub fn is_persona_shared_kind(kind: u32) -> bool { - kind == KIND_PERSONA +/// event carries exactly `["shared", "true"]`. Every relay read chokepoint +/// consults this set: REQ historical delivery, live fan-out, COUNT fallback, +/// the `ids`-lookup result gate, both HTTP surfaces, and the pre-`LIMIT` SQL +/// visibility pushdown in `buzz-db`. +/// +/// Membership is a privacy decision, not a convenience: adding a kind here +/// makes its events invisible to foreign readers until their author opts in, +/// and the opt-in must be a `shared` TAG (not a content field) so that +/// toggling it leaves content bytes — and any content hash derived from them — +/// unchanged. +/// +/// `KIND_TEAM` (30176) is deliberately NOT a member. Its writers never emit +/// `shared`, so catalog opt-in semantics do not describe it; it needs +/// owner-private read semantics instead, which is a separate change. +pub const SHARED_GATED_KINDS: &[u32] = &[KIND_PERSONA, KIND_TEAM_CATALOG]; + +/// Returns `true` if `kind` uses the author-only-unless-shared read model +/// (see [`SHARED_GATED_KINDS`]). +pub fn is_shared_gated_kind(kind: u32) -> bool { + SHARED_GATED_KINDS.contains(&kind) } -/// Returns `true` if the event is a persona-shared-catalog kind AND the -/// requester is NOT the author AND the event does NOT carry `["shared", -/// "true"]`. All three conditions must hold to withhold the event. +/// Returns `true` if the event is a shared-gated kind AND the requester is NOT +/// the author AND the event does NOT carry `["shared", "true"]`. All three +/// conditions must hold to withhold the event. /// /// This is the per-event gate used by REQ historical delivery, live fan-out, /// and COUNT fallback paths. It is intentionally independent of -/// `is_author_only_event` — persona events with `["shared", "true"]` MUST +/// `is_author_only_event` — shared-gated events with `["shared", "true"]` MUST /// reach foreign readers; stripping them at the author-only layer would break /// the catalog query. -pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { +pub fn is_unshared_gated_event(event: &nostr::Event, requester_pubkey_bytes: &[u8]) -> bool { let kind = event.kind.as_u16() as u32; - if !is_persona_shared_kind(kind) { + if !is_shared_gated_kind(kind) { return false; } // Author reads are always allowed. @@ -212,18 +226,23 @@ pub fn is_unshared_persona_event(event: &nostr::Event, requester_pubkey_bytes: & return false; } // Foreign reader: allowed only if the event is explicitly shared. - !persona_event_is_shared(event) + !event_is_shared(event) } /// Returns `true` if the event carries exactly one `["shared", "true"]` tag. /// +/// Kind-agnostic: this is purely the tag-shape predicate. The kind check lives +/// in [`is_shared_gated_kind`], so callers that need "is this event shared" +/// for a kind they already know (e.g. a client deciding whether its own +/// retained head is published) can use this directly. +/// /// Requires the tag to have exactly two elements so that a three-element shape /// like `["shared","true","extra"]` is NOT treated as shared. Ingest enforces /// the same exact shape, so a well-stored event either has no `shared` tag /// (author-only) or exactly one with precisely two elements and value `"true"` /// (community-readable). This helper fails closed on any non-exact shape /// independently of ingest guarantees. -pub fn persona_event_is_shared(event: &nostr::Event) -> bool { +pub fn event_is_shared(event: &nostr::Event) -> bool { let mut count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); @@ -258,6 +277,34 @@ pub const KIND_TEAM: u32 = 30176; /// since these events are world-readable on the relay. pub const KIND_MANAGED_AGENT: u32 = 30177; +/// NIP-AP: Team Catalog projection (parameterized replaceable, owner-authored). +/// +/// The shareable projection of a team, addressed by `(pubkey, kind, d_tag)` +/// where `d_tag` is the team's stable id. Content is a versioned JSON body +/// carrying sanitized team fields plus ordered, EMBEDDED member definition +/// projections. +/// +/// # Why this is not a `shared` tag on [`KIND_TEAM`] +/// +/// A team's members live in kind 30175 events that are author-only unless +/// individually shared, so a foreign reader of a shared team could never +/// hydrate its members. This kind therefore embeds the member projections +/// rather than referencing them: the share is atomic, it covers built-in +/// members that have no 30175 head at all, it is immune to local-id/d-tag +/// divergence, and an unshared 30175 stays private. Kind 30176's wire body is +/// untouched, so device sync keeps its contract. +/// +/// # Access control +/// +/// Member of [`SHARED_GATED_KINDS`]: author-only unless the event carries +/// exactly `["shared", "true"]`. Ingest additionally requires exactly one +/// non-empty, bounded `d` tag — generic NIP-33 storage maps a missing `d` to +/// the empty coordinate, which would collapse every team into one slot. +/// +/// Content carries only sanitized fields: no env vars, no `respond_to` +/// allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. +pub const KIND_TEAM_CATALOG: u32 = 30178; + // NIP-56 reporting /// NIP-56: Report an event, pubkey, or blob to relay moderators (kind:1984). /// @@ -586,6 +633,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_PERSONA, KIND_TEAM, KIND_MANAGED_AGENT, + KIND_TEAM_CATALOG, KIND_REPORT, KIND_PRODUCT_FEEDBACK, KIND_NIP29_PUT_USER, @@ -784,6 +832,7 @@ const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000– const _: () = assert!(is_parameterized_replaceable(KIND_PERSONA)); // 30175 ∈ 30000–39999 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_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 @@ -858,64 +907,68 @@ mod tests { } } - // ── persona_event_is_shared / is_unshared_persona_event ────────────── + // ── event_is_shared / is_unshared_gated_event ──────────────────────── - fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + fn make_event_of_kind(kind: u32, tags: &[&[&str]]) -> nostr::Event { use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); let tag_vec: Vec = tags .iter() .map(|parts| Tag::parse(parts.iter().copied()).unwrap()) .collect(); - EventBuilder::new(Kind::Custom(KIND_PERSONA as u16), "") + EventBuilder::new(Kind::Custom(kind as u16), "") .tags(tag_vec) .sign_with_keys(&keys) .unwrap() } + fn make_persona_event(tags: &[&[&str]]) -> nostr::Event { + make_event_of_kind(KIND_PERSONA, tags) + } + #[test] - fn persona_event_is_shared_true_tag() { + fn event_is_shared_true_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); - assert!(persona_event_is_shared(&ev)); + assert!(event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_no_tag() { + fn event_is_shared_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_wrong_value() { + fn event_is_shared_wrong_value() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "false"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_duplicate_shared_tags() { + fn event_is_shared_duplicate_shared_tags() { // Two ["shared","true"] tags → ambiguous; not considered shared. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"], &["shared", "true"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_three_element_tag_not_shared() { + fn event_is_shared_three_element_tag_not_shared() { // ["shared","true","extra"] — three elements — must NOT be treated as shared. // The helper fails closed on any non-exact shape independently of ingest guarantees. let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true", "extra"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn persona_event_is_shared_one_element_tag_not_shared() { + fn event_is_shared_one_element_tag_not_shared() { // ["shared"] — only one element — not shared (fails the == 2 check). let ev = make_persona_event(&[&["d", "my-agent"], &["shared"]]); - assert!(!persona_event_is_shared(&ev)); + assert!(!event_is_shared(&ev)); } #[test] - fn is_unshared_persona_event_author_always_allowed() { + fn is_unshared_gated_event_author_always_allowed() { // Even without a shared tag the event author should not be blocked. use nostr::{EventBuilder, Keys, Kind, Tag}; let keys = Keys::generate(); @@ -924,32 +977,83 @@ mod tests { .sign_with_keys(&keys) .unwrap(); let author_bytes = keys.public_key().to_bytes(); - assert!(!is_unshared_persona_event(&ev, &author_bytes)); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); } #[test] - fn is_unshared_persona_event_foreign_no_tag() { + fn is_unshared_gated_event_foreign_no_tag() { let ev = make_persona_event(&[&["d", "my-agent"]]); let foreign = [0u8; 32]; - assert!(is_unshared_persona_event(&ev, &foreign)); + assert!(is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_foreign_shared_tag() { + fn is_unshared_gated_event_foreign_shared_tag() { let ev = make_persona_event(&[&["d", "my-agent"], &["shared", "true"]]); let foreign = [0u8; 32]; - assert!(!is_unshared_persona_event(&ev, &foreign)); + assert!(!is_unshared_gated_event(&ev, &foreign)); } #[test] - fn is_unshared_persona_event_non_persona_kind_passthrough() { + fn is_unshared_gated_event_ungated_kind_passthrough() { use nostr::{EventBuilder, Keys, Kind}; let keys = Keys::generate(); let ev = EventBuilder::new(Kind::Custom(KIND_TEAM as u16), "") .sign_with_keys(&keys) .unwrap(); let foreign = [0u8; 32]; - // Non-persona kinds are never blocked by this gate. - assert!(!is_unshared_persona_event(&ev, &foreign)); + // Kinds outside SHARED_GATED_KINDS are never blocked by this gate. + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_no_tag() { + // The gate must cover 30178 identically to 30175 — an unshared team + // catalog projection is author-only. + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"]]); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_foreign_shared_tag() { + let ev = make_event_of_kind(KIND_TEAM_CATALOG, &[&["d", "team-1"], &["shared", "true"]]); + let foreign = [0u8; 32]; + assert!(!is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_author_always_allowed() { + use nostr::{EventBuilder, Keys, Kind, Tag}; + let keys = Keys::generate(); + let ev = EventBuilder::new(Kind::Custom(KIND_TEAM_CATALOG as u16), "") + .tags(vec![Tag::parse(["d", "team-1"]).unwrap()]) + .sign_with_keys(&keys) + .unwrap(); + let author_bytes = keys.public_key().to_bytes(); + assert!(!is_unshared_gated_event(&ev, &author_bytes)); + } + + #[test] + fn is_unshared_gated_event_team_catalog_malformed_shared_tag_fails_closed() { + // A three-element `shared` tag can never be stored (ingest rejects it), + // but the read gate must independently treat it as NOT shared. + let ev = make_event_of_kind( + KIND_TEAM_CATALOG, + &[&["d", "team-1"], &["shared", "true", "extra"]], + ); + let foreign = [0u8; 32]; + assert!(is_unshared_gated_event(&ev, &foreign)); + } + + #[test] + fn shared_gated_kinds_membership() { + assert!(is_shared_gated_kind(KIND_PERSONA)); + assert!(is_shared_gated_kind(KIND_TEAM_CATALOG)); + // 30176 has owner-private semantics, not catalog opt-in semantics: its + // writers never emit `shared`, so gating it here would hide every team + // from its own delegated readers. + assert!(!is_shared_gated_kind(KIND_TEAM)); + assert!(!is_shared_gated_kind(KIND_MANAGED_AGENT)); } } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 0e54196d11..c0550e7e22 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -11,7 +11,7 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, - KIND_HUDDLE_STARTED, + KIND_HUDDLE_STARTED, SHARED_GATED_KINDS, }; use buzz_core::{CommunityId, StoredEvent}; @@ -71,13 +71,15 @@ pub struct EventQuery { /// which needs to fetch all matching events for post-filter counting. /// When None, the default clamp of 1000 applies. pub max_limit: Option, - /// Persona visibility reader: when set, append an SQL visibility clause - /// for kind 30175 before ORDER/LIMIT so private personas are excluded from - /// the candidate page rather than discarded after it. + /// Shared-gated visibility reader: when set, append an SQL visibility + /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so + /// private events are excluded from the candidate page rather than + /// discarded after it. /// - /// The clause is: `AND (kind != 30175 OR pubkey = $reader OR tags @> ?)`, - /// where `?` is the JSONB literal `[["shared","true"]]`. The GIN index on - /// `tags` (migration 0004, jsonb_path_ops) makes the containment check fast. + /// The clause is: `AND (kind NOT IN (...) OR pubkey = $reader OR tags @> ?)`, + /// where the `IN` list is [`SHARED_GATED_KINDS`] and `?` is the JSONB + /// literal `[["shared","true"]]`. The GIN index on `tags` (migration 0004, + /// jsonb_path_ops) makes the containment check fast. /// /// NOTE: `tags @> '[["shared","true"]]'` uses JSONB containment, which /// matches any tag array that is a superset of `[["shared","true"]]` — it @@ -85,7 +87,7 @@ pub struct EventQuery { /// 2` exact-shape check ensures such malformed tags are never stored, so the /// SQL pushdown is sound. Keeping `event_visible_to_reader` as post-filter /// defense-in-depth catches any residual mismatch. - pub persona_reader: Option>, + pub shared_gated_reader: Option>, } impl EventQuery { @@ -114,7 +116,7 @@ impl EventQuery { e_tags: None, channel_ids: None, max_limit: None, - persona_reader: None, + shared_gated_reader: None, } } } @@ -512,25 +514,28 @@ pub(crate) async fn query_events_on( } } - // Persona visibility pushdown: exclude kind 30175 events that are neither - // authored by the reader nor explicitly shared. Applied BEFORE ORDER/LIMIT - // so that a page of newer private personas does not push visible shared ones - // off the end of the result set (the catalog query pattern). + // Shared-gated visibility pushdown: exclude SHARED_GATED_KINDS events that + // are neither authored by the reader nor explicitly shared. Applied BEFORE + // ORDER/LIMIT so that a page of newer private events does not push visible + // shared ones off the end of the result set (the catalog query pattern). // - // Clause: AND (kind != 30175 OR pubkey = $reader OR tags @> '[["shared","true"]]') + // Clause: AND (kind NOT IN (30175, 30178) OR pubkey = $reader + // OR tags @> '[["shared","true"]]') // // The JSONB containment check is served by idx_events_tags_gin (migration // 0004, jsonb_path_ops). `tags @> '[["shared","true"]]'` matches any array // that contains exactly the sub-array — a two-element `["shared","true"]` - // tag passes; a tag-absent event does not. Because ingest now requires - // exactly two elements for the shared tag (parts.len() == 2), no stored - // event can carry a three-element superset. - if let Some(ref reader_bytes) = q.persona_reader { - let kind_30175: i32 = 30175; + // tag passes; a tag-absent event does not. Because ingest requires exactly + // two elements for the shared tag (parts.len() == 2), no stored event can + // carry a three-element superset. + if let Some(ref reader_bytes) = q.shared_gated_reader { let shared_containment = serde_json::json!([["shared", "true"]]); - qb.push(format!(" AND ({col_prefix}kind != ")); - qb.push_bind(kind_30175); - qb.push(format!(" OR {col_prefix}pubkey = ")); + qb.push(format!(" AND ({col_prefix}kind NOT IN (")); + let mut sep = qb.separated(", "); + for kind in SHARED_GATED_KINDS { + sep.push_bind(*kind as i32); + } + qb.push(format!(") OR {col_prefix}pubkey = ")); qb.push_bind(reader_bytes.clone()); qb.push(format!(" OR {col_prefix}tags @> ")); qb.push_bind(shared_containment); diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 10461d8d46..678199e734 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1236,10 +1236,10 @@ async fn query_events_authed( extract_channel_from_filter(filter), &accessible_channels, ); - // Persona visibility pushdown: must mirror WS REQ so that a page of newer - // private personas does not starve older shared ones off the candidate page. - if crate::handlers::req::filter_can_match_persona_shared_kinds(filter) { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: must mirror WS REQ so that a page of + // newer private events does not starve older shared ones off the page. + if crate::handlers::req::filter_can_match_shared_gated_kinds(filter) { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } match extract_before_id(raw) { @@ -1453,11 +1453,11 @@ async fn count_events_authed( filter, &authed_pubkey_hex, ); - // Force per-event fallback for filters that can match kind:30175 — - // the fast SQL count_events() path has no per-event gate and would - // over-count foreign unshared persona events (existence leak). - let needs_persona_filtering = - crate::handlers::req::filter_can_match_persona_shared_kinds(filter); + // Force per-event fallback for filters that can match a shared-gated + // kind — the fast SQL count_events() path has no per-event gate and + // would over-count foreign unshared events (existence leak). + let needs_shared_gate_filtering = + crate::handlers::req::filter_can_match_shared_gated_kinds(filter); // If filter targets a specific channel, verify access. if let Some(ch_id) = extract_channel_from_filter(filter) { @@ -1472,10 +1472,10 @@ async fn count_events_authed( tenant.community(), ) .await; - // Persona visibility pushdown: same as REQ and /query paths, so the - // fallback's query_events call doesn't over-fetch private persona rows. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: same as REQ and /query paths, so + // the fallback's query_events call doesn't over-fetch private rows. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -1486,7 +1486,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("bridge_count", &query).await { Ok(n) => total += n as u64, @@ -1541,10 +1541,10 @@ async fn count_events_authed( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown: pre-filter before ORDER/LIMIT on the - // fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter before ORDER/LIMIT on + // the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -1556,7 +1556,7 @@ async fn count_events_authed( if crate::handlers::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; match state.db.count_events_routed("bridge_count", &query).await { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index dfb44e152f..3eeab5e807 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -7,8 +7,8 @@ use tracing::warn; use crate::connection::{AuthState, ConnectionState}; use crate::handlers::req::{ - event_visible_to_reader, filter_can_match_persona_shared_kinds, - filter_can_match_result_gated_kinds, result_gated_count_safe_for_pushdown, + event_visible_to_reader, filter_can_match_result_gated_kinds, + filter_can_match_shared_gated_kinds, result_gated_count_safe_for_pushdown, }; use crate::protocol::RelayMessage; use crate::state::AppState; @@ -103,11 +103,11 @@ pub async fn handle_count( // fast-path count_events() cannot be used because it doesn't do // per-event author filtering. let needs_author_only_filtering = super::req::filter_can_match_author_only_kinds(filter); - // Determine if this filter can match kind 30175 (persona) — if so, the - // fast-path must be bypassed because it has no per-event shared-tag check. - // A fast count over 30175 would include foreign unshared persona events, - // leaking the existence of private agent activity. - let needs_persona_filtering = filter_can_match_persona_shared_kinds(filter); + // Determine if this filter can match a shared-gated kind (30175, 30178) + // — if so, the fast path must be bypassed because it has no per-event + // shared-tag check. A fast count over those kinds would include foreign + // unshared events, leaking the existence of private agent activity. + let needs_shared_gate_filtering = filter_can_match_shared_gated_kinds(filter); // Determine if this filter can match result-gated kinds (44200, 30622) // that require a per-event owner check. When the fast SQL path would // count matching rows without calling reader_authorized_for_event, a @@ -157,10 +157,10 @@ pub async fn handle_count( conn.tenant.community(), ) .await; - // Persona visibility pushdown: pre-filter the fallback query_events - // candidate page before ORDER/LIMIT. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: pre-filter the fallback + // query_events candidate page before ORDER/LIMIT. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { !authors.is_empty() @@ -171,7 +171,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { match state.db.count_events_routed("count_req", &query).await { Ok(n) => total += n as u64, @@ -230,9 +230,9 @@ pub async fn handle_count( ) .await; query.channel_ids = Some(accessible_channels.to_vec()); - // Persona visibility pushdown for the fallback query_events path. - if needs_persona_filtering { - query.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown for the fallback query_events path. + if needs_shared_gate_filtering { + query.shared_gated_reader = Some(pubkey_bytes.clone()); } let author_is_self = filter.authors.as_ref().is_some_and(|authors| { @@ -244,7 +244,7 @@ pub async fn handle_count( if super::req::filter_fully_pushable(filter) && (!needs_author_only_filtering || author_is_self) && !needs_result_gated_filtering - && !needs_persona_filtering + && !needs_shared_gate_filtering { query.limit = None; // COUNT doesn't need a row limit match state.db.count_events_routed("count_req", &query).await { diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index 88dd5f5180..a9cdffcdec 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -7,7 +7,7 @@ use tracing::{debug, error, info, warn}; use buzz_core::event::StoredEvent; use buzz_core::kind::{ - event_kind_u32, is_ephemeral, is_unshared_persona_event, AUTHOR_ONLY_KINDS, + event_kind_u32, is_ephemeral, is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, }; use buzz_core::observer::{ @@ -151,10 +151,10 @@ pub async fn filter_fanout_by_access( matches }; - // Persona shared-read gate (fan-out): kind 30175 events fan out to all - // connections only when carrying ["shared","true"]. Unshared personas - // are delivered only to the author's own connections, matching REQ semantics. - let matches = if buzz_core::kind::is_persona_shared_kind(event_kind_u32(&stored_event.event)) { + // Shared-read gate (fan-out): SHARED_GATED_KINDS events fan out to all + // connections only when carrying ["shared","true"]. Unshared ones are + // delivered only to the author's own connections, matching REQ semantics. + let matches = if buzz_core::kind::is_shared_gated_kind(event_kind_u32(&stored_event.event)) { let author = stored_event.event.pubkey.to_bytes(); matches .into_iter() @@ -167,7 +167,7 @@ pub async fn filter_fanout_by_access( return true; } // Foreign connection: allowed only if the event is shared. - !is_unshared_persona_event(&stored_event.event, &pk) + !is_unshared_gated_event(&stored_event.event, &pk) }) .collect() } else { diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index ee644d5a9b..39ecbe18e4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -31,9 +31,9 @@ use buzz_core::kind::{ KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, - KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEXT_NOTE, KIND_USER_STATUS, - KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, - RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, + KIND_USER_STATUS, KIND_WORKFLOW_DEF, KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, + RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, RELAY_ADMIN_SET_WORKSPACE_PROFILE, }; use buzz_core::tenant::TenantContext; use buzz_core::verification::verify_event; @@ -214,7 +214,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::MessagesWrite), KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS | KIND_AGENT_ENGRAM | KIND_EVENT_REMINDER | KIND_PERSONA | KIND_TEAM | KIND_MANAGED_AGENT - | super::push_lease::KIND_PUSH_LEASE => { + | KIND_TEAM_CATALOG | super::push_lease::KIND_PUSH_LEASE => { Ok(Scope::UsersWrite) } // NIP-AM: agent turn metrics are agent-authored global events (encrypted to owner). @@ -419,10 +419,12 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_AGENT_PROFILE // NIP-AP: persona definitions (30175): owner-authored, keyed by (pubkey, kind, d_tag). | KIND_PERSONA - // NIP-AP: team (30176) + managed-agent (30177) definitions: owner-authored, - // keyed by (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. + // NIP-AP: team (30176) + managed-agent (30177) definitions and the + // team-catalog projection (30178): owner-authored, keyed by + // (pubkey, kind, d_tag). A stray `h` tag must not channel-scope them. | KIND_TEAM | KIND_MANAGED_AGENT + | KIND_TEAM_CATALOG // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). | KIND_GIT_REPO_ANNOUNCEMENT @@ -1029,37 +1031,27 @@ fn validate_engram_envelope(event: &Event) -> Result<(), String> { Ok(()) } -/// Validate the envelope of a kind:30175 persona event. -/// -/// Enforces: -/// * exactly one `d` tag with a non-empty value matching the slug grammar -/// `^[a-z0-9][a-z0-9_-]{0,63}$`. -/// * at most one `shared` tag; if present, its value must be exactly `"true"`. +/// Enforce the `shared`-tag shape shared by every kind in +/// [`buzz_core::kind::SHARED_GATED_KINDS`]: at most one `shared` tag, and if +/// present it must be exactly `["shared", "true"]`. /// -/// Without the `d`-tag check, an empty d-tag collapses every persona into the -/// `(pubkey, 30175, "")` slot — last-write-wins data loss. +/// This ensures no ambiguous heads: either an event has no `shared` tag +/// (author-only) or exactly `["shared", "true"]` (community-readable). Any +/// other value (`"false"`, `"1"`, extra elements, duplicate tags) is rejected +/// at ingest so read-path helpers — including the SQL-level `tags @> +/// '[["shared","true"]]'` containment clause, which would otherwise match a +/// three-element superset — can treat stored events as unambiguously one or the +/// other. /// -/// The `shared` tag rule ensures no ambiguous heads: either an event has no -/// `shared` tag (author-only) or exactly `["shared", "true"]` (community- -/// readable). Any other value (`"false"`, `"1"`, extra tags) is rejected at -/// ingest so read-path helpers can treat stored events as unambiguously one or -/// the other. -fn validate_persona_envelope(event: &Event) -> Result<(), String> { - let mut d_tags: Vec<&str> = Vec::new(); +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn validate_shared_tag(event: &Event, label: &str) -> Result<(), String> { let mut shared_count = 0usize; for tag in event.tags.iter() { let parts = tag.as_slice(); - if parts.len() >= 2 && parts[0].as_str() == "d" { - d_tags.push(&parts[1]); - } if !parts.is_empty() && parts[0].as_str() == "shared" { - // Exact shape required: ["shared", "true"] — exactly two elements, - // second element exactly "true". Extra elements are rejected so that - // a three-element tag like ["shared","true","extra"] cannot be stored - // and later misread as shared by the SQL-level visibility clause. if parts.len() != 2 || parts[1].as_str() != "true" { return Err(format!( - "persona event `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", + "{label} `shared` tag must be exactly [\"shared\",\"true\"] (got {:?})", parts.iter().map(|s| s.as_str()).collect::>() )); } @@ -1068,43 +1060,106 @@ fn validate_persona_envelope(event: &Event) -> Result<(), String> { } if shared_count > 1 { return Err(format!( - "persona event must have at most one `shared` tag (got {shared_count})" + "{label} must have at most one `shared` tag (got {shared_count})" )); } + Ok(()) +} + +/// Return the event's single `d` tag value, requiring exactly one tag whose +/// value is non-empty, at most 64 characters, and free of Unicode control +/// characters and whitespace. +/// +/// Without this check an empty `d` tag collapses every event of the kind into +/// the `(pubkey, kind, "")` slot — last-write-wins data loss. The character +/// bound keeps the value usable as a NIP-33 coordinate (`::`) +/// and as a log field: an embedded newline or tab would break line-oriented +/// consumers of both. +/// +/// Tags are counted by their first element alone, so a valueless `["d"]` +/// counts. Skipping it would let `["d"]` plus `["d", "team-1"]` pass the +/// exactly-one rule, and a NIP-33 consumer that reads `["d"]` as an +/// empty-valued first `d` tag would then address the event at `""` where this +/// relay addresses it at `"team-1"`. +/// +/// `label` names the kind in error messages (e.g. `"persona event"`). +fn single_bounded_d_tag<'a>(event: &'a Event, label: &str) -> Result<&'a str, String> { + let d_tags: Vec> = event + .tags + .iter() + .filter_map(|tag| { + let parts = tag.as_slice(); + (parts.first().map(|name| name.as_str()) == Some("d")) + .then(|| parts.get(1).map(|value| value.as_str())) + }) + .collect(); if d_tags.len() != 1 { return Err(format!( - "persona event must have exactly one `d` tag (got {})", + "{label} must have exactly one `d` tag (got {})", d_tags.len() )); } - let d = d_tags[0]; + let d = d_tags[0].unwrap_or_default(); if d.is_empty() { - return Err("persona event `d` tag must not be empty".to_string()); + return Err(format!("{label} `d` tag must not be empty")); } - // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ - if d.len() > 64 { + let char_count = d.chars().count(); + if char_count > 64 { return Err(format!( - "persona event `d` tag too long ({} chars, max 64)", - d.len() + "{label} `d` tag too long ({char_count} chars, max 64)" )); } + if d.chars().any(|c| c.is_control() || c.is_whitespace()) { + return Err(format!( + "{label} `d` tag must not contain control characters or whitespace" + )); + } + Ok(d) +} + +/// Validate the envelope of a kind:30175 persona event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one `d` tag matching the persona slug grammar +/// `^[a-z0-9][a-z0-9_-]{0,63}$`. +fn validate_persona_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "persona event"; + validate_shared_tag(event, LABEL)?; + let d = single_bounded_d_tag(event, LABEL)?; + // Slug grammar: ^[a-z0-9][a-z0-9_-]{0,63}$ let bytes = d.as_bytes(); if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() { - return Err( - "persona event `d` tag must start with a lowercase letter or digit".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must start with a lowercase letter or digit" + )); } if !bytes[1..] .iter() .all(|&b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') { - return Err( - "persona event `d` tag must match [a-z0-9_-] after the first character".to_string(), - ); + return Err(format!( + "{LABEL} `d` tag must match [a-z0-9_-] after the first character" + )); } Ok(()) } +/// Validate the envelope of a kind:30178 team-catalog event. +/// +/// Enforces the shared-gated `shared`-tag shape ([`validate_shared_tag`]) plus +/// exactly one non-empty, bounded `d` tag. +/// +/// Deliberately NOT the persona slug grammar: a team's `d` tag is its stable +/// local id, which is either a UUID or a built-in identifier such as +/// `builtin-team:welcome` — the colon is not slug-legal, and rewriting ids to +/// fit would break NIP-33 addressing against the team's own kind:30176 head. +fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { + const LABEL: &str = "team-catalog event"; + validate_shared_tag(event, LABEL)?; + single_bounded_d_tag(event, LABEL)?; + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2070,6 +2125,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_TEAM_CATALOG { + validate_team_catalog_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3597,6 +3657,24 @@ mod tests { assert!(err.contains("`d` tag"), "got: {err}"); } + #[test] + fn persona_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_persona(&[&["d"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn persona_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event, breaking the exactly-one rule. + let ev = make_persona(&[&["d"], &["d", "slug-a"]]); + let err = validate_persona_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + #[test] fn persona_envelope_rejects_too_long() { let slug = "a".repeat(65); @@ -3723,6 +3801,151 @@ mod tests { ); } + // ─── team-catalog (30178) envelope tests ───────────────────────────────── + + fn make_team_catalog(tags: &[&[&str]]) -> Event { + make_event_with_tags( + KIND_TEAM_CATALOG, + r#"{"v":1,"name":"Team","members":[]}"#, + tags, + ) + } + + #[test] + fn team_catalog_envelope_accepts_uuid_d_tag() { + let ev = make_team_catalog(&[&["d", "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_builtin_colon_d_tag() { + // Built-in team ids carry a colon (`builtin-team:welcome`), which the + // persona slug grammar forbids. The catalog `d` tag must accept them so + // a built-in team can be shared under its real local id. + let ev = make_team_catalog(&[&["d", "builtin-team:welcome"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_accepts_shared_true() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_missing_d_tag() { + let ev = make_team_catalog(&[]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_empty_d_tag() { + // An empty d-tag collapses every team into the (pubkey, 30178, "") slot. + let ev = make_team_catalog(&[&["d", ""]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_d_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["d", "team-2"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_d_tag() { + // A lone ["d"] carries no value; it must fail as a missing value, not + // be skipped as though the event had no `d` tag at all. + let ev = make_team_catalog(&[&["d"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("must not be empty"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_valueless_plus_valued_d_tags() { + // Counting only tags with a value would see one `d` here and accept the + // event. A NIP-33 consumer that reads ["d"] as an empty-valued first + // `d` tag would then address this event at "" where we address it at + // "team-1". + let ev = make_team_catalog(&[&["d"], &["d", "team-1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("exactly one `d` tag"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_bounds_d_tag_by_chars_not_bytes() { + // 64 multi-byte characters is 192 bytes; the documented bound is + // characters, so this must be accepted. + let d = "é".repeat(64); + assert!(d.len() > 64, "fixture must exceed the bound in bytes"); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_too_long_d_tag() { + let d = "a".repeat(65); + let ev = make_team_catalog(&[&["d", &d]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("too long"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_accepts_max_length_d_tag() { + let d = "a".repeat(64); + let ev = make_team_catalog(&[&["d", &d]]); + assert!(validate_team_catalog_envelope(&ev).is_ok()); + } + + #[test] + fn team_catalog_envelope_rejects_whitespace_d_tag() { + // A newline in the d-tag would break the NIP-33 coordinate and any + // line-oriented log consumer. + let ev = make_team_catalog(&[&["d", "team\n1"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("control characters"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_false() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "false"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("\"true\""), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_shared_three_elements() { + // Same exact-shape rule as personas: a three-element tag would match the + // SQL containment clause `tags @> '[["shared","true"]]'` as a superset. + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true", "extra"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("[\"shared\",\"true\"]"), "got: {err}"); + } + + #[test] + fn team_catalog_envelope_rejects_duplicate_shared_tags() { + let ev = make_team_catalog(&[&["d", "team-1"], &["shared", "true"], &["shared", "true"]]); + let err = validate_team_catalog_envelope(&ev).unwrap_err(); + assert!(err.contains("at most one"), "got: {err}"); + } + + #[test] + fn team_catalog_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_TEAM_CATALOG, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn team_catalog_is_global_only() { + assert!(is_global_only_kind(KIND_TEAM_CATALOG)); + assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 51400452d7..35fbf0c892 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -7,8 +7,8 @@ use tracing::{debug, warn}; use buzz_core::filter::filters_match; use buzz_core::kind::{ - is_unshared_persona_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, - KIND_DM_VISIBILITY, KIND_PERSONA, P_GATED_KINDS, RESULT_GATED_KINDS, + is_unshared_gated_event, AUTHOR_ONLY_KINDS, KIND_AGENT_ENGRAM, KIND_AGENT_TURN_METRIC, + KIND_DM_VISIBILITY, P_GATED_KINDS, RESULT_GATED_KINDS, SHARED_GATED_KINDS, }; use buzz_core::tenant::TenantContext; use buzz_db::EventQuery; @@ -290,11 +290,11 @@ pub async fn handle_req( let mut params = filter_to_query_params(filter, per_filter_channel, conn.tenant.community()); apply_access_scope_to_query(&mut params, per_filter_channel, &accessible_channels); - // Persona visibility pushdown: set reader bytes so query_events appends - // the SQL visibility clause before ORDER/LIMIT, preventing newer private - // personas from starving older shared ones off the page. - if filter_can_match_persona_shared_kinds(filter) { - params.persona_reader = Some(pubkey_bytes.clone()); + // Shared-gated visibility pushdown: set reader bytes so query_events + // appends the SQL visibility clause before ORDER/LIMIT, preventing + // newer private events from starving older shared ones off the page. + if filter_can_match_shared_gated_kinds(filter) { + params.shared_gated_reader = Some(pubkey_bytes.clone()); } (idx, per_filter_channel, params) }) @@ -1137,19 +1137,20 @@ pub(crate) fn filter_can_match_author_only_kinds(filter: &Filter) -> bool { }) } -/// Returns `true` if the filter CAN match kind 30175 (persona) — meaning it -/// either has no `kinds` constraint (wildcard) or explicitly includes 30175. +/// Returns `true` if the filter CAN match any kind in [`SHARED_GATED_KINDS`] — +/// meaning it either has no `kinds` constraint (wildcard) or explicitly includes +/// one of them. /// /// Used by the COUNT handler to force the per-event fallback path, which calls -/// `is_unshared_persona_event` on each row. The fast SQL `count_events()` path +/// `is_unshared_gated_event` on each row. The fast SQL `count_events()` path /// has no per-event access check, so it would over-count foreign unshared -/// persona events — leaking the existence of persona activity even without -/// returning content. -pub(crate) fn filter_can_match_persona_shared_kinds(filter: &Filter) -> bool { - filter - .kinds - .as_ref() - .is_none_or(|ks| ks.iter().any(|k| k.as_u16() as u32 == KIND_PERSONA)) +/// events — leaking the existence of private persona/team-catalog activity even +/// without returning content. +pub(crate) fn filter_can_match_shared_gated_kinds(filter: &Filter) -> bool { + filter.kinds.as_ref().is_none_or(|ks| { + ks.iter() + .any(|k| SHARED_GATED_KINDS.contains(&(k.as_u16() as u32))) + }) } /// Returns `true` if the filter CAN match result-gated kinds — meaning it @@ -1208,8 +1209,9 @@ pub(crate) fn is_author_only_event(event: &nostr::Event, requester_pubkey_bytes: /// /// 1. **Author-only kinds** (`AUTHOR_ONLY_KINDS`, e.g. kind 30300/30350): only /// the author may read their own events. -/// 2. **Persona shared-gate** (kind 30175 without `["shared","true"]`): the -/// event is only visible to the author unless explicitly opted into sharing. +/// 2. **Shared-gate** (`SHARED_GATED_KINDS`, e.g. kind 30175/30178 without +/// `["shared","true"]`): the event is only visible to the author unless +/// explicitly opted into sharing. /// 3. **Result-gated kinds** (kind 44200/30622 etc.): `reader_authorized_for_event` /// carries the per-event ownership check. /// @@ -1223,7 +1225,7 @@ pub(crate) fn event_visible_to_reader(event: &nostr::Event, requester_pubkey_byt if is_author_only_event(event, requester_pubkey_bytes) { return false; } - if is_unshared_persona_event(event, requester_pubkey_bytes) { + if is_unshared_gated_event(event, requester_pubkey_bytes) { return false; } let requester_pubkey_hex = hex::encode(requester_pubkey_bytes); diff --git a/crates/buzz-test-client/tests/e2e_persona.rs b/crates/buzz-test-client/tests/e2e_persona.rs index b3b1f7f6b2..4f37e22e16 100644 --- a/crates/buzz-test-client/tests/e2e_persona.rs +++ b/crates/buzz-test-client/tests/e2e_persona.rs @@ -1324,7 +1324,7 @@ async fn test_persona_http_query_cross_author_gate() { /// /// A foreign authenticated caller counting `{kinds:[30175],authors:[victim]}` /// must count only shared heads — not unshared ones — on both the fast SQL -/// path (prevented by `needs_persona_filtering`) and the fallback path. +/// path (prevented by `needs_shared_gate_filtering`) and the fallback path. #[tokio::test] #[ignore] async fn test_persona_http_count_cross_author_gate() { @@ -1403,7 +1403,7 @@ async fn test_persona_http_count_cross_author_gate() { /// event is returned. /// /// Verifies at `312014d5e`: this test fails there because `query_events` did -/// not have the `persona_reader` SQL clause and the private rows starved the +/// not have the `shared_gated_reader` SQL clause and the private rows starved the /// shared one off the page. #[tokio::test] #[ignore] diff --git a/crates/buzz-test-client/tests/e2e_team_catalog.rs b/crates/buzz-test-client/tests/e2e_team_catalog.rs new file mode 100644 index 0000000000..ce313d1fe9 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_team_catalog.rs @@ -0,0 +1,484 @@ +//! End-to-end tests for kind:30178 team-catalog events (NIP-AP). +//! +//! Kind 30178 is the shareable projection of a team. It joins kind:30175 in +//! `SHARED_GATED_KINDS`, so these tests assert the wire behaviour of that gate +//! at every read chokepoint (REQ, `ids` lookup, COUNT, live fan-out) plus the +//! ingest envelope rules that make the gate sound: +//! - Exactly one non-empty, bounded `d` tag — the team's stable local id, which +//! may contain a colon (`builtin-team:welcome`) unlike a persona slug. +//! - `shared`, if present, is exactly `["shared", "true"]`. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test --test e2e_team_catalog -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::{BuzzTestClient, RelayMessage}; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const TEAM_CATALOG_KIND: u16 = 30178; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-team-catalog-{name}-{}", uuid::Uuid::new_v4()) +} + +fn catalog_content(name: &str) -> String { + serde_json::json!({ "v": 1, "name": name, "members": [] }).to_string() +} + +/// Build a kind:30178 event, optionally carrying the `["shared","true"]` opt-in. +fn catalog_event(keys: &Keys, d_tag: &str, shared: bool) -> nostr::Event { + catalog_event_at(keys, d_tag, shared, Timestamp::now().as_secs()) +} + +/// Same as [`catalog_event`] with an explicit `created_at`, so NIP-33 head +/// ordering is deterministic instead of resolved by event-id tie-break. +fn catalog_event_at(keys: &Keys, d_tag: &str, shared: bool, created_at: u64) -> nostr::Event { + let mut tags = vec![Tag::parse(["d", d_tag]).unwrap()]; + if shared { + tags.push(Tag::parse(["shared", "true"]).unwrap()); + } + EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Test Team"), + ) + .tags(tags) + .custom_created_at(Timestamp::from(created_at)) + .sign_with_keys(keys) + .unwrap() +} + +fn author_filter(author: &Keys) -> Filter { + Filter::new() + .kind(Kind::Custom(TEAM_CATALOG_KIND)) + .author(author.public_key()) +} + +fn coordinate_filter(author: &Keys, d_tag: &str) -> Filter { + author_filter(author).custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +fn d_tag_of(event: &nostr::Event) -> Option<&str> { + event.tags.iter().find_map(|t| { + let parts = t.as_slice(); + if parts.first().map(|p| p.as_str()) != Some("d") { + return None; + } + Some(parts.get(1)?.as_str()) + }) +} + +/// The author's own unshared projection round-trips at its NIP-33 coordinate. +/// +/// The `d` tag is a UUID, matching the desktop team id — proof the envelope does +/// NOT apply the persona slug grammar. +#[tokio::test] +#[ignore] +async fn test_team_catalog_publish_and_query_own_unshared() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let event = catalog_event(&keys, &d_tag, false); + let event_id = event.id; + let ok = client.send_event(event).await.expect("send catalog"); + assert!(ok.accepted, "relay rejected catalog event: {}", ok.message); + + let sid = sub_id("own-unshared"); + client + .subscribe(&sid, vec![coordinate_filter(&keys, &d_tag)]) + .await + .expect("subscribe"); + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "author must see own unshared projection"); + assert_eq!(events[0].id, event_id); + + client.disconnect().await.expect("disconnect"); +} + +/// A built-in team id (`builtin-team:welcome`) is accepted as the `d` tag. +/// +/// The colon is illegal in a persona slug; rewriting the id to fit would break +/// NIP-33 addressing against the team's own kind:30176 head. +#[tokio::test] +#[ignore] +async fn test_team_catalog_accepts_builtin_colon_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = format!("builtin-team:{}", &uuid::Uuid::new_v4().to_string()[..8]); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, &d_tag, true)) + .await + .expect("send catalog"); + assert!( + ok.accepted, + "relay rejected colon-bearing team id: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses an empty `d` tag: generic NIP-33 storage maps it to the empty +/// coordinate, collapsing every team into one `(pubkey, 30178, "")` slot. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_empty_d_tag() { + let url = relay_url(); + let keys = Keys::generate(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client + .send_event(catalog_event(&keys, "", false)) + .await + .expect("send catalog"); + assert!(!ok.accepted, "empty d-tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a valueless `["d"]` tag alongside a valued one. Counting only +/// tags that carry a value would see exactly one `d` here and accept the event; +/// a NIP-33 consumer that reads `["d"]` as an empty-valued first `d` tag would +/// then address the event at `""` where this relay addresses it at the team id. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_valueless_plus_valued_d_tags() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Two d tags"), + ) + .tags(vec![ + Tag::parse(["d"]).unwrap(), + Tag::parse(["d", d_tag.as_str()]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!( + !ok.accepted, + "a valueless `d` tag must count toward the exactly-one rule" + ); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// Ingest refuses a malformed `shared` tag. A three-element tag would satisfy +/// the SQL containment clause `tags @> '[["shared","true"]]'` as a superset +/// while the in-process gate reads it as unshared — the two layers must agree, +/// so such an event can never be stored. +#[tokio::test] +#[ignore] +async fn test_team_catalog_rejects_three_element_shared_tag() { + let url = relay_url(); + let keys = Keys::generate(); + let d_tag = uuid::Uuid::new_v4().to_string(); + + let event = EventBuilder::new( + Kind::Custom(TEAM_CATALOG_KIND), + catalog_content("Malformed"), + ) + .tags(vec![ + Tag::parse(["d", d_tag.as_str()]).unwrap(), + Tag::parse(["shared", "true", "extra"]).unwrap(), + ]) + .sign_with_keys(&keys) + .unwrap(); + + let mut client = BuzzTestClient::connect(&url, &keys).await.expect("connect"); + let ok = client.send_event(event).await.expect("send catalog"); + assert!(!ok.accepted, "three-element shared tag must be rejected"); + assert!( + ok.message.contains("invalid:"), + "expected an `invalid:` refusal, got: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// REQ historical delivery: a foreign reader receives only shared projections, +/// while the author receives both of their own. +#[tokio::test] +#[ignore] +async fn test_team_catalog_foreign_sees_only_shared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_unshared = format!("priv-{}", uuid::Uuid::new_v4()); + let d_shared = format!("pub-{}", uuid::Uuid::new_v4()); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let shared_event = catalog_event(&author_keys, &d_shared, true); + let shared_id = shared_event.id; + let ok = author + .send_event(catalog_event(&author_keys, &d_unshared, false)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared ingest rejected: {}", ok.message); + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared ingest rejected: {}", ok.message); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fg-all"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + !events + .iter() + .any(|e| d_tag_of(e) == Some(d_unshared.as_str())), + "foreign reader must NOT see the unshared projection" + ); + assert!( + events.iter().any(|e| e.id == shared_id), + "foreign reader must see the shared projection" + ); + + let sid_author = sub_id("auth-all"); + author + .subscribe(&sid_author, vec![author_filter(&author_keys)]) + .await + .expect("subscribe author"); + let author_events = author + .collect_until_eose(&sid_author, Duration::from_secs(5)) + .await + .expect("collect author"); + assert!( + author_events.len() >= 2, + "author must see both own projections, got {}", + author_events.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Knowing an event id does NOT grant access: `{ids:[unshared]}` returns nothing +/// to a foreign reader. +#[tokio::test] +#[ignore] +async fn test_team_catalog_ids_lookup_unshared_returns_nothing_to_foreign() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let event = catalog_event(&author_keys, &uuid::Uuid::new_v4().to_string(), false); + let event_id = event.id; + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author.send_event(event).await.expect("send"); + assert!(ok.accepted, "ingest rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("ids-unshared"); + foreign + .subscribe(&sid, vec![Filter::new().id(event_id)]) + .await + .expect("subscribe"); + let events = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.is_empty(), + "ids-lookup of an unshared projection must return nothing, got {:?}", + events.iter().map(|e| e.id).collect::>() + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// COUNT must take the per-event fallback for kind:30178 so the aggregate does +/// not leak the existence of unshared projections. +#[tokio::test] +#[ignore] +async fn test_team_catalog_count_excludes_foreign_unshared() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + false, + )) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + let ok = author + .send_event(catalog_event( + &author_keys, + &uuid::Uuid::new_v4().to_string(), + true, + )) + .await + .expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + author.disconnect().await.expect("disconnect author"); + + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("count"); + let count_msg = serde_json::json!(["COUNT", sid, author_filter(&author_keys)]); + foreign.send_raw(&count_msg).await.expect("send COUNT"); + + let count = match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Count { count, .. }) => count, + Ok(RelayMessage::Closed { message, .. }) => panic!("COUNT closed unexpectedly: {message}"), + Ok(other) => panic!("unexpected relay message for COUNT: {other:?}"), + Err(e) => panic!("unexpected error for COUNT: {e}"), + }; + assert_eq!( + count, 1, + "foreign COUNT must see only the shared projection, got {count}" + ); + + foreign.disconnect().await.expect("disconnect foreign"); +} + +/// Live fan-out honours the gate, and unsharing (a NIP-33 replacement that drops +/// the `shared` tag) retracts the projection from foreign readers. +#[tokio::test] +#[ignore] +async fn test_team_catalog_live_fanout_and_unshare_retracts() { + let url = relay_url(); + let author_keys = Keys::generate(); + let foreign_keys = Keys::generate(); + + let d_tag = uuid::Uuid::new_v4().to_string(); + let now = Timestamp::now().as_secs(); + let (t0, t1, t2) = (now.saturating_sub(2), now.saturating_sub(1), now); + + // Subscribe BEFORE publishing, scoped to this author so parallel tests + // publishing their own 30178s cannot trip the leak assertion. + let mut foreign = BuzzTestClient::connect(&url, &foreign_keys) + .await + .expect("connect foreign"); + let sid = sub_id("fanout"); + foreign + .subscribe(&sid, vec![author_filter(&author_keys)]) + .await + .expect("subscribe"); + let _ = foreign + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("drain eose"); + + let mut author = BuzzTestClient::connect(&url, &author_keys) + .await + .expect("connect author"); + + // Unshared publish must NOT reach the foreign connection. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t0)) + .await + .expect("send unshared"); + assert!(ok.accepted, "unshared rejected: {}", ok.message); + match foreign.recv_event(Duration::from_millis(750)).await { + Err(buzz_test_client::TestClientError::Timeout) => {} + Ok(RelayMessage::Event { event, .. }) if event.kind == Kind::Custom(TEAM_CATALOG_KIND) => { + panic!("unshared projection leaked to foreign live subscription"); + } + Ok(_) => {} + Err(e) => panic!("unexpected error awaiting fan-out: {e}"), + } + + // Shared replacement MUST reach it. + let shared_event = catalog_event_at(&author_keys, &d_tag, true, t1); + let shared_id = shared_event.id; + let ok = author.send_event(shared_event).await.expect("send shared"); + assert!(ok.accepted, "shared rejected: {}", ok.message); + let delivered = loop { + match foreign.recv_event(Duration::from_secs(5)).await { + Ok(RelayMessage::Event { event, .. }) if event.id == shared_id => break true, + Ok(_) => continue, + Err(buzz_test_client::TestClientError::Timeout) => break false, + Err(e) => panic!("unexpected error awaiting shared fan-out: {e}"), + } + }; + assert!( + delivered, + "shared projection must fan out to foreign readers" + ); + + // Unshare: replace at the same coordinate without the tag. Subsequent + // foreign REQs must return nothing. + let ok = author + .send_event(catalog_event_at(&author_keys, &d_tag, false, t2)) + .await + .expect("send unshare"); + assert!(ok.accepted, "unshare rejected: {}", ok.message); + + let sid_post = sub_id("post-unshare"); + foreign + .subscribe(&sid_post, vec![coordinate_filter(&author_keys, &d_tag)]) + .await + .expect("subscribe post"); + let after = foreign + .collect_until_eose(&sid_post, Duration::from_secs(5)) + .await + .expect("collect post"); + assert!( + after.is_empty(), + "unsharing must retract the projection from foreign readers, got {} event(s)", + after.len() + ); + + author.disconnect().await.expect("disconnect author"); + foreign.disconnect().await.expect("disconnect foreign"); +} diff --git a/desktop/src-tauri/src/commands/personas/pending.rs b/desktop/src-tauri/src/commands/personas/pending.rs index a4003329bc..cab5fababc 100644 --- a/desktop/src-tauri/src/commands/personas/pending.rs +++ b/desktop/src-tauri/src/commands/personas/pending.rs @@ -75,11 +75,11 @@ pub(super) fn prepare_persona_publication( } fn retained_persona_is_shared(row: Option<&RetainedEvent>) -> bool { - use buzz_core_pkg::kind::persona_event_is_shared; + use buzz_core_pkg::kind::event_is_shared; use nostr::JsonUtil; row.and_then(|retained| nostr::Event::from_json(&retained.raw_event).ok()) - .is_some_and(|event| persona_event_is_shared(&event)) + .is_some_and(|event| event_is_shared(&event)) } /// Project each persona's catalog visibility from the active relay+owner diff --git a/desktop/src-tauri/src/event_sync.rs b/desktop/src-tauri/src/event_sync.rs index d9fe6acdb9..ee8e0d8b10 100644 --- a/desktop/src-tauri/src/event_sync.rs +++ b/desktop/src-tauri/src/event_sync.rs @@ -165,7 +165,7 @@ fn migrate_personas_in_dir_at( scoped_record.shared = existing .as_ref() .and_then(|row| nostr::Event::from_json(&row.raw_event).ok()) - .is_some_and(|event| buzz_core_pkg::kind::persona_event_is_shared(&event)); + .is_some_and(|event| buzz_core_pkg::kind::event_is_shared(&event)); let event = build_persona_event(&scoped_record) .map_err(|e| format!("failed to build event for '{}': {e}", record.display_name))? .custom_created_at(monotonic_created_at( diff --git a/desktop/src-tauri/src/managed_agents/persona_events.rs b/desktop/src-tauri/src/managed_agents/persona_events.rs index ea61a811db..6afc18a501 100644 --- a/desktop/src-tauri/src/managed_agents/persona_events.rs +++ b/desktop/src-tauri/src/managed_agents/persona_events.rs @@ -5,7 +5,7 @@ use std::collections::BTreeMap; -use buzz_core_pkg::kind::{persona_event_is_shared, KIND_PERSONA}; +use buzz_core_pkg::kind::{event_is_shared, KIND_PERSONA}; use nostr::{EventBuilder, Kind, Tag}; use serde::{Deserialize, Serialize}; @@ -192,7 +192,7 @@ pub fn persona_from_event(event: &nostr::Event) -> Result:`. Unsharing is distinct from deletion — it is a newer valid head at the same coordinate published *without* the `shared` tag, which keeps the projection readable to its author while retracting it from foreign readers. + ## Relationships to other NIPs ### NIP-AE (Agent Engrams) @@ -216,6 +218,29 @@ surface per-event errors. Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an `auth` tag proving that `pubkey_o` authorized the agent's key. The persona event itself does not contain attestation; it is the *definition* from which attestation is issued at spawn time. +## Team catalog projection: kind:30178 + +Kind `30178` is the **shareable projection of a team**: owner-authored, parameterized replaceable, addressed by `(pubkey_o, 30178, d)` where `d` is the team's stable local id. Its `content` is a versioned JSON body carrying sanitized team fields plus ordered, *embedded* member definition projections. The content schema is defined by the client that publishes it; this section specifies only the envelope and the relay's contract. + +```jsonc +{ + "kind": 30178, + "pubkey": "", + "created_at": , + "tags": [ + ["d", ""], + ["shared", "true"] // optional; presence opts the projection into community reads + ], + "content": "" +} +``` + +**Why a separate kind rather than a `shared` tag on the team event (kind:30176).** A team's members are `kind:30175` definitions, which are author-only unless individually shared — so a foreign reader of a shared team could never hydrate its members. Kind `30178` embeds the member projections instead of referencing them: the share is atomic, it covers built-in members that have no `30175` head at all, it is immune to local-id/`d`-tag divergence, and an unshared `30175` stays private. Kind `30176`'s wire body is untouched, so device sync keeps its contract. + +**The `d` tag is a team id, not a persona slug.** It is either a UUID or a built-in identifier such as `builtin-team:welcome`. The colon is illegal under the persona slug grammar, and rewriting ids to fit would break NIP-33 addressing against the team's own `kind:30176` head — so the relay applies a laxer rule (see below) to `30178` than to `30175`. + +**Content carries only sanitized fields.** No environment variables, no `respond_to` allowlist pubkeys, no source or local ids, no filesystem paths, no secrets. Sharing a team makes the team's and every member's instructions community-readable plaintext. + ## Relay behavior ### Ingest validation @@ -226,10 +251,20 @@ Agents spawned from a persona carry [NIP-OA](NIP-OA.md) owner attestation — an - The relay MUST enforce that the `d` tag is non-empty (standard NIP-33 requirement for parameterized replaceable events). - The relay MUST enforce shared-tag shape: if a `shared` tag is present, it MUST consist of **exactly two elements** — `["shared", "true"]`. Extra elements (e.g. `["shared","true","extra"]`), wrong values (`["shared","false"]`), missing values (`["shared"]`), or duplicate `shared` tags are all rejected with `invalid:`. The two-element exact-shape constraint is required so that the relay's SQL visibility clause (`tags @> '[["shared","true"]]'`) never matches a stored malformed tag via JSONB containment supersets. +### Ingest validation: kind:30178 + +Kind `30178` is stored globally and its content is unvalidated, exactly as for `30175`. The envelope rules differ in one respect — the `d` grammar: + +- The relay MUST enforce the same `shared`-tag exact shape as `30175`, for the same reason: the read gate and the SQL containment clause must agree on every stored event. +- The relay MUST enforce **exactly one** `d` tag whose value is non-empty, at most 64 characters, and free of Unicode control characters and whitespace. Tags are counted by their first element, so a valueless `["d"]` counts toward the total and fails the value check on its own — otherwise `["d"]` alongside `["d",""]` would pass, and a consumer that reads `["d"]` as an empty-valued first `d` tag would address the event at `""` while this relay addresses it at ``. Without the non-empty check, generic NIP-33 storage maps a missing or empty `d` to the empty coordinate, collapsing every team into the single `(pubkey_o, 30178, "")` slot — last-write-wins data loss. The character bound keeps the value usable as a NIP-33 coordinate and as a log field. +- The relay MUST NOT apply the persona slug grammar to a `30178` `d` tag; team ids legitimately contain characters (notably `:`) that the slug grammar forbids. + ### Access control: author-only-unless-shared Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts and `respond_to_allowlist` from being visible to all community members as a side-effect of device sync. +The gate is kind-generic: the relay applies it to every kind in `SHARED_GATED_KINDS` (`buzz-core/src/kind.rs`), currently `30175` and the `30178` team-catalog projection described below. The rules and enforcement surfaces are identical for each member kind. + **Rules:** | Event state | Author reads | Foreign reads | @@ -239,13 +274,13 @@ Kind `30175` uses **shared-tag-gated read semantics** to protect system prompts These rules are enforced at the following relay read surfaces (content and event existence are withheld on all of them): -- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`persona_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. +- **REQ historical delivery** — foreign requests silently omit unshared persona events, even in mixed-kind filters (`{kinds:[30175,9]}`). The visibility check is applied **before `ORDER BY … LIMIT`** at the SQL level (`shared_gated_reader` field in `EventQuery`), so a page of newer private personas cannot starve an older shared persona off the candidate set — the catalog's primary all-author query pattern is correctly served. - **NIP-01 `ids` lookup** — knowing an event id does NOT grant access to an unshared persona. The result gate returns nothing. - **Live fan-out** — unshared personas are delivered only to the author's connections. Shared personas fan out community-wide. -- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match `kind:30175`. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. -- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `persona_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. -- **NIP-98 HTTP bridge `/count`** — `needs_persona_filtering` forces the per-event fallback path for any filter that can match `kind:30175`; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. -- **FTS (NIP-50 search) and `/search`** — kind `30175` is not in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared persona. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. +- **COUNT** — the fast SQL `count_events()` path is bypassed when the filter can match a shared-gated kind. A per-event fallback applies the shared-tag check, preventing existence-leak via COUNT. +- **NIP-98 HTTP bridge `/query`** — the same per-event visibility check is applied to the catchall post-processing loop. The SQL-level `shared_gated_reader` clause also applies before `LIMIT`, preventing older shared personas from being starved by newer private ones on paginated catalog queries. A foreign caller POSTing `{kinds:[30175],authors:[victim]}` or a kindless `{ids:[...]}` filter to `/query` receives no unshared persona content. +- **NIP-98 HTTP bridge `/count`** — `needs_shared_gate_filtering` forces the per-event fallback path for any filter that can match a shared-gated kind; the fast SQL `count_events()` path is not used. Both the channel-scoped and unconstrained fallback loops apply `event_visible_to_reader`, preventing existence-leak via COUNT over HTTP. +- **FTS (NIP-50 search) and `/search`** — no shared-gated kind is in the relay's FTS allowlist (migration 8 indexes only kinds `0, 9, 40002, 45001, 45003`); no FTS result can contain an unshared event. A defense-in-depth check is also present in the bridge search result loop so that a future FTS allowlist change cannot silently reopen the bypass. **Device sync is unaffected.** The sync subscription (`{kinds:[30175], authors:[self]}`) reads the author's own events, which are always returned regardless of shared state. @@ -263,6 +298,7 @@ These rules are enforced at the following relay read surfaces (content and event - **Slug collision across pubkeys.** Two different owners can publish personas with the same slug. Clients MUST always scope queries by author pubkey, not just slug. - **Metadata exposure.** The `(pubkey, kind:30175, slug)` triple reveals persona existence. Event timestamps reveal edit history. - **No owner write authority over agents.** Persona events define *what* an agent should be; they do not grant runtime control over a running agent. The agent consumes the persona at spawn time. Updates to the persona event do not automatically propagate to running agents. +- **Sharing a team shares every member's instructions.** A `kind:30178` head carrying `["shared","true"]` exposes the team's own fields *and* the embedded projection of every member — including members whose own `kind:30175` heads are unshared and therefore still private. Clients MUST make this explicit at the point of sharing; the relay cannot infer it. ## Reference test vectors From 29dfe4821ed577489a1879fd2a9bfe2a621a52b3 Mon Sep 17 00:00:00 2001 From: Sumit Madan <33051892+sumit-m@users.noreply.github.com> Date: Fri, 31 Jul 2026 03:30:22 +0530 Subject: [PATCH 23/87] fix(desktop): don't gate hover affordances on the hover media query (#3657) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What problem this solves Tailwind v4 compiles every `hover:` variant inside `@media (hover: hover)`. Some Windows hosts answer that query `false` **even with a mouse attached**, and then every hover-revealed control in the app is permanently `visibility: hidden`. Measured in the app's own WebView2 devtools console, on a mouse-driven Windows 11 desktop: ```js matchMedia('(hover: hover)').matches // false matchMedia('(any-hover: hover)').matches // false matchMedia('(pointer: fine)').matches // false matchMedia('(any-pointer: fine)').matches // false navigator.maxTouchPoints // 10 ``` Windows itself, on the same machine at the same moment, reports a mouse present and an integrated digitizer: ``` GetSystemMetrics(SM_DIGITIZER) = 197 // INTEGRATED_TOUCH | INTEGRATED_PEN // | MULTI_INPUT | READY GetSystemMetrics(SM_MAXIMUMTOUCHES) = 10 SystemInformation.MousePresent = True ``` So this is not "the user has no mouse". Windows knows a mouse is attached, and Chromium still reports `any-pointer: fine: false` and `any-hover: false` — the `any-*` queries exist precisely to describe *any* available input device, and they are wrong here. The presence of an integrated touch digitizer collapses the reported capability to touch-only. The compiled rule that never applies: ```css .group-hover\/member\:visible { &:is(:where(.group\/member):hover *) { @media (hover: hover) { visibility: visible; } } } ``` The row genuinely matches `:hover` (verified: `row.matches(':hover') === true`), the button is in the DOM, the utility class is generated — and the declaration still never lands. ## Why this is more than one control Not a single menu. Confirmed newly-ungated in the production bundle after the change: | utility | media-gated before | after | |---|---|---| | `group-hover/member:visible` | yes | no | | `group-hover/inbox-item:opacity-100` | yes | no | | `group-hover/channel-row:opacity-100` | yes | no | | `group-hover/attachment:opacity-100` | yes | no | | `hover:bg-muted` | yes | no | On an affected host the channel-member action menu (remove member, change role, start/stop agent) has **no reachable affordance at all**: `visibility: hidden` also removes the button from tab order, so there is no keyboard path either. ## The fix One line, at the root, next to the existing variant override: ```css @custom-variant hover (&:hover); ``` This trusts the actual hover event rather than the capability query. Chromium only fires `:hover` when a real pointer is present, so behaviour on hosts that report the capability correctly is unchanged. Verified against a production `vite build`, not just the dev server — the override cascades to the *named* group variants (`group-hover/member`, etc.), which is the part that matters here. ## Prior art in this repo #2849 overrides Tailwind v4's `dark:` variant default at the *exact same insertion point* in this file, for the same class of reason (a v4 default that does not match how this app actually works). This change follows that precedent. **Note for whoever merges second: #2849 and this PR will conflict textually** — both append a `@custom-variant` immediately after `@config`. The resolution is to keep both lines; they are independent. ## Scope Desktop only. `web/src/shared/styles/globals.css` has the same Tailwind v4 default, but `web/src` contains **zero** `group-hover` usages, so there are no hover-revealed affordances to strand there. Adding the override to web would be speculative. One `hover` capability query is deliberately left in place — `.buzz-wave-hover-trigger` in `animations.css` gates a decorative wave-hand animation on `(hover: hover) and (pointer: fine)`. That is a cosmetic flourish rather than an affordance, so it stays inert on affected hosts instead of widening this diff. ## Reproducing The trigger is **an integrated touch digitizer anywhere on the machine**, not the display you are actually working on. This was found on a touch-capable laptop docked to an ordinary non-touch external monitor, driven entirely by a mouse — so "I'm on a desktop monitor" does not rule you out. Check with: ```js matchMedia('(hover: hover)').matches // false ⇒ affected ``` Not reproducible on macOS, or on a Windows machine with no digitizer at all — `hover: hover` is true there and every affordance works normally. If you are on such a host, emulate it in devtools by forcing `hover: none` / `pointer: coarse`, then open a channel's member list and hover a row: no action menu appears. ## Tradeoff worth naming On a genuine touch-only device, a bare `&:hover` can latch after a tap and stay applied until the next interaction, where the media-query default would have suppressed it. That is the real cost of this change. The judgement here is that a stuck hover style is a cosmetic annoyance, while an unreachable "remove member" button is a functional dead end — and that the affected hosts are overwhelmingly mouse-driven machines that merely *happen* to ship a digitizer, as the `MousePresent = True` reading above shows. If you would rather scope this to `@media not (hover: hover)` as an additive fallback instead of overriding the variant, I am happy to rework it. Signed-off-by: sumit-m <33051892+sumit-m@users.noreply.github.com> --- desktop/src/shared/styles/globals.css | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/desktop/src/shared/styles/globals.css b/desktop/src/shared/styles/globals.css index fd60621933..704f6e542d 100644 --- a/desktop/src/shared/styles/globals.css +++ b/desktop/src/shared/styles/globals.css @@ -1,5 +1,6 @@ @import "tailwindcss"; @import "tw-animate-css"; + @import "./globals/scrollbars.css"; @import "./globals/motion.css"; @import "./globals/animations.css"; @@ -17,3 +18,15 @@ @import "./globals/progress.css"; @config "../../../tailwind.config.js"; + +/* Tailwind v4 gates `hover:` behind `@media (hover: hover)`. Some Windows + hosts answer that query `false` even with a mouse attached — WebView2 here + reports `hover: none`, `any-pointer: fine: false`, `maxTouchPoints: 10` — + which leaves every hover-revealed control permanently `visibility: hidden`: + member row action menus, sidebar row actions, attachment controls. A bare + `&:hover` trusts the actual hover event instead of the capability query; + Chromium only fires :hover when a real pointer is present. + + Must stay below every `@import`: CSS requires `@import` to precede other + at-rules, so placing this above them silently drops the rest of the sheet. */ +@custom-variant hover (&:hover); From 74cd5712191bffd84ae688d59bb8b451c6eec1b0 Mon Sep 17 00:00:00 2001 From: Wes Date: Thu, 30 Jul 2026 16:04:14 -0600 Subject: [PATCH 24/87] fix(desktop): report authenticated relay recovery (#3812) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - report the relay as connected immediately after socket open and successful AUTH - keep rate-limited subscription replay, the connect promise, and reconnect listeners unchanged - cover authenticated reconnect while replay is held behind the shared rate-limit gate ## Why After WARP recovery, the socket could reopen and authenticate successfully while subscription replay waited behind the existing rate-limit gate. `connect()` kept `ConnectionState` at `reconnecting` during that intentional delay, so the desktop displayed “Can’t reach the relay” despite authenticated traffic already flowing. This is separate from #3774: that fix keeps routine operations from bypassing scheduled reconnect backoff. This patch preserves those protections and only corrects the authenticated transport-state boundary. ## Failure semantics If replay fails after the early `connected` transition, the existing `replayLiveSubscriptions()` catch calls `resetConnection()`, closes the socket, returns state to `reconnecting`, and schedules recovery. Operation waiters and reconnect notifications still do not complete until replay succeeds. ## Validation At commit `c8a4308e1079f4f9e6a72f0f0bfba280fe822ec0` with a clean working tree: - `pnpm --dir desktop typecheck` - `pnpm --dir desktop test` — 3,847 passed - `pnpm --dir desktop check` — passed; two pre-existing informational template-literal notices - `pnpm --dir desktop exec playwright test tests/e2e/relay-reconnect.spec.ts` — 8 passed - regression test proven red before the production ordering change (`reconnecting` after 3 seconds) and green after it Signed-off-by: Wes Co-authored-by: Carl --- desktop/src/shared/api/relayClientSession.ts | 2 +- desktop/src/testing/e2eBridge.ts | 5 +++ desktop/tests/e2e/relay-reconnect.spec.ts | 35 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 8274034ed5..94438386eb 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -566,8 +566,8 @@ export class RelayClient { this.reconnectDelayMs = RECONNECT_BASE_DELAY_MS; }, BACKOFF_RESET_STABLE_MS); - await this.replayLiveSubscriptions(); this.connectionStateEmitter.set("connected"); + await this.replayLiveSubscriptions(); this.stallWatchdog.start(); this.emitReconnectIfNeeded(); } catch (error) { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 841e6ba83f..9bb3feabda 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -11,6 +11,7 @@ import { } from "./e2eBridgeCustomHarnesses.ts"; import { relayClient } from "@/shared/api/relayClient"; +import { activateRateLimit } from "@/shared/api/relayRateLimitGate"; import type { ConnectionState } from "@/shared/api/relayClientShared"; import type { ChannelTemplate, RelayEvent } from "@/shared/api/types"; import { getMarkdownParseCount } from "@/shared/ui/markdown/nodeCache"; @@ -1115,6 +1116,7 @@ declare global { unavailable: boolean, ) => void; __BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => number[]; + __BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__?: (seconds: number) => void; __BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__?: () => void; __BUZZ_E2E_SET_MESH__?: (mesh: { admitted?: boolean; @@ -9706,6 +9708,9 @@ export function maybeInstallE2eTauriMocks() { window.__BUZZ_E2E_GET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => [ ...relayWebsocketConnectAttemptStarts, ]; + window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__ = (seconds) => { + activateRateLimit(seconds); + }; window.__BUZZ_E2E_RESET_WEBSOCKET_CONNECT_ATTEMPTS__ = () => { relayWebsocketConnectAttemptStarts.length = 0; }; diff --git a/desktop/tests/e2e/relay-reconnect.spec.ts b/desktop/tests/e2e/relay-reconnect.spec.ts index 67ce725da8..6606d5f04d 100644 --- a/desktop/tests/e2e/relay-reconnect.spec.ts +++ b/desktop/tests/e2e/relay-reconnect.spec.ts @@ -62,6 +62,19 @@ async function setMockWebsocketUnavailable( }, unavailable); } +async function activateRelayRateLimit( + page: import("@playwright/test").Page, + seconds: number, +) { + await page.evaluate((duration) => { + const activate = window.__BUZZ_E2E_ACTIVATE_RELAY_RATE_LIMIT__; + if (!activate) { + throw new Error("E2E relay rate-limit seam is not installed."); + } + activate(duration); + }, seconds); +} + async function getMockWebsocketConnectAttempts( page: import("@playwright/test").Page, ) { @@ -195,6 +208,28 @@ test("routine traffic cannot bypass outage backoff and recovery stays automatic" ); }); +test("authenticated reconnect reports connected while replay is rate-limited", async ({ + page, +}) => { + await page.goto("/"); + await expect(page.getByTestId("channel-general")).toBeVisible(); + + await activateRelayRateLimit(page, 5); + await disconnectMockWebsockets(page); + + // Replay remains intentionally blocked behind admission control, but socket + // open + successful AUTH is already a healthy connection. The UI must not + // claim the relay is unreachable for the rest of the gate window. + await expect + .poll( + () => + page.evaluate(() => window.__BUZZ_E2E_GET_RELAY_CONNECTION_STATE__?.()), + { timeout: 3_000 }, + ) + .toBe("connected"); + await expect(page.getByTestId("sidebar-relay-unreachable")).toHaveCount(0); +}); + test("service restart close resets accumulated backoff", async ({ page }) => { await installMockBridge(page, { websocketConnectErrors: ["down 1", "down 2", "down 3"], From 36571f4adcfdcf3714a17bd968c58c78bcbdd9ef Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 18:11:03 -0400 Subject: [PATCH 25/87] fix(desktop): allow linux-only media items as dead code off-linux (#3811) Local `desktop-tauri-clippy` fails on macOS with dead-code errors for `PROD_ORIGIN`, `DEV_ORIGIN`, and `is_trusted_media_origin`, which are only used inside `#[cfg(target_os = "linux")] enable_media_capture`. The items are intentionally platform-independent so unit tests run everywhere. Added `cfg_attr` allow attribute to suppress the warnings on non-Linux targets. Since [#3607](https://github.com/block/buzz/pull/3607), this affects all Rust developers on macOS. Signed-off-by: Will Pfleger Co-authored-by: d32955ad69077062930cc46cfe2df30ca9aaf6f8e76422681265e9e9af704d78 --- desktop/src-tauri/src/linux_media.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/desktop/src-tauri/src/linux_media.rs b/desktop/src-tauri/src/linux_media.rs index c768e15422..240e2f8a77 100644 --- a/desktop/src-tauri/src/linux_media.rs +++ b/desktop/src-tauri/src/linux_media.rs @@ -22,17 +22,22 @@ //! which is the backend WebKitGTK media capture is reliable on. /// The origin Tauri serves the packaged app from on Linux. +/// Consumed only by linux-gated [`enable_media_capture`]; kept compiling on all +/// platforms so the unit tests run everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const PROD_ORIGIN: &str = "tauri://localhost"; /// The Vite dev-server origin (`devUrl` in `tauri.conf.json`, `strictPort` /// 1420 in `vite.config.ts`). Only trusted in debug builds. #[cfg(debug_assertions)] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] const DEV_ORIGIN: &str = "http://localhost:1420"; /// Whether `uri` (the webview's current document URI) is a trusted app origin /// allowed to use mic/camera. Matches the origin exactly or as a path prefix so /// `tauri://localhost.evil.com` and `http://localhost:14200` do not slip /// through. Pure and platform-independent so it can be unit-tested everywhere. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] fn is_trusted_media_origin(uri: &str) -> bool { fn matches(uri: &str, origin: &str) -> bool { uri == origin From 23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 30 Jul 2026 18:42:18 -0400 Subject: [PATCH 26/87] fix(relay): align NIP-11 max_limit with REQ ceiling (#3635) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz's NIP-11 document advertised `limitation.max_limit: 10_000`, but the effective websocket REQ page ceiling was `1_000` — a 10x lie. The websocket REQ path never sets `EventQuery::max_limit`, so `query_events` applied its own `unwrap_or(1000)` clamp to every historical query. Only the COUNT fallback (`apply_count_fallback_limit`) ever raises that clamp. A client that trusts the advertised value asks for 10,000 events, silently receives 1,000, and — with no error and no continuation signal — reads that short page as exhaustion. Up to 9,000 events are dropped without anyone noticing. `MAX_HISTORICAL_LIMIT = 2_000` in `handlers/req.rs` was dead weight for the same reason: nothing clamped to 2,000 could survive the DB's 1,000 clamp one layer down. ## Change `buzz_db::DEFAULT_MAX_PAGE_LIMIT` (`1_000`) is now the single source of truth. It is the `query_events` clamp default, the value both REQ clamp sites use, and the value advertised as NIP-11 `max_limit`. `MAX_HISTORICAL_LIMIT` is removed rather than re-pointed — an alias for a constant used four lines away adds a name without adding meaning. The NIP-50 search path carries a second, independent bound. It clamps its emission target to the shared ceiling like any other REQ, but how many FTS candidates it will scan was bounded separately, by a bare 10-page loop over 100-hit pages. That product only coincidentally equalled the ceiling, so raising the ceiling — or shrinking a page — would shrink the scan relative to what clients may now request, degrading search quality while nothing in the code registered the change. The page count is now ceiling-divided from `DEFAULT_MAX_PAGE_LIMIT` over a named `SEARCH_PAGE_SIZE`, so the scan budget tracks the advertised ceiling by construction. That budget is a resource policy, not a delivery promise. It bounds candidates *scanned*, not events *emitted*: post-filtering (NIP-01 match, channel access, reader visibility, dedup) discards an unpredictable share of every page, so a search result smaller than the requested limit remains possible. This is not a NIP-11 violation — `max_limit` is defined as a clamp the relay applies to a requested `limit`, not a guaranteed count in the response. Two guards hold the pair together: - `req_filter_limit_clamps_to_advertised_nip11_max_limit` reads `max_limit` back out of a built `RelayInfo` and asserts the REQ path clamps to exactly that number. - `search_scan_capacity_covers_advertised_nip11_max_limit` asserts the scan budget covers exactly one advertised ceiling's worth of candidates — no less, and with no spare page of slack, so the derivation can't be quietly replaced by a hand-tuned constant that happens to pass today. ## Behavior Websocket behavior is unchanged: 1,000 was already the real ceiling on every path, including NIP-50. The advertisement now tells the truth about it. Raising the effective limit is a capacity decision and is deliberately not made here. The generic HTTP bridge's page-2+ offsets do change, as a consequence of the corrected clamp. `extract_page_offset` sizes a page from `query.limit` *before* the DB clamp applies, so an absent limit previously produced an offset of 2,000 and a requested 1,500 produced 1,500 — while the page actually returned held at most 1,000 rows. Both now produce 1,000. This corrects paging that had been skipping rows the previous page never returned; `extract_page_offset_sizes_pages_from_clamped_limit` locks it down. ## Scope note The bridge's per-endpoint ceilings — `BRIDGE_WINDOW_MAX_LIMIT` (200) for channel windows and `BRIDGE_THREAD_MAX_LIMIT` (500) for thread reads — are endpoint contracts on a non-NIP-01 transport, not values NIP-11 speaks for, and are unchanged. Fixes #3757 --------- Signed-off-by: Will Pfleger Co-authored-by: Duncan --- crates/buzz-db/src/event.rs | 15 ++- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-relay/src/api/bridge.rs | 21 +++++ crates/buzz-relay/src/handlers/req.rs | 126 ++++++++++++++++++++++---- crates/buzz-relay/src/nip11.rs | 7 +- 5 files changed, 147 insertions(+), 24 deletions(-) diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index c0550e7e22..6c84950a2c 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -17,6 +17,13 @@ use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; +/// Largest page [`query_events`] will return when [`EventQuery::max_limit`] is +/// unset — the effective ceiling on any client-requested `limit`. +/// +/// This is the value the relay advertises as NIP-11 `limitation.max_limit`, so +/// the advertised ceiling and the enforced one cannot drift. +pub const DEFAULT_MAX_PAGE_LIMIT: i64 = 1_000; + /// Optional filters for [`query_events`]. #[derive(Debug, Clone)] pub struct EventQuery { @@ -67,9 +74,9 @@ pub struct EventQuery { /// channel-less global events. Applied before SQL `LIMIT` so access-filtered /// historical pages have exact exhaustion semantics. pub channel_ids: Option>, - /// Override the default limit clamp (1000). Used by COUNT fallback path - /// which needs to fetch all matching events for post-filter counting. - /// When None, the default clamp of 1000 applies. + /// Override the default page clamp ([`DEFAULT_MAX_PAGE_LIMIT`]). Used by + /// the COUNT fallback path, which needs to fetch all matching events for + /// post-filter counting. When None, the default clamp applies. pub max_limit: Option, /// Shared-gated visibility reader: when set, append an SQL visibility /// clause for every kind in [`SHARED_GATED_KINDS`] before ORDER/LIMIT so @@ -357,7 +364,7 @@ pub(crate) async fn query_events_on( return Ok(vec![]); } - let clamp = q.max_limit.unwrap_or(1000); + let clamp = q.max_limit.unwrap_or(DEFAULT_MAX_PAGE_LIMIT); let limit_val = q.limit.unwrap_or(100).min(clamp); let offset_val = q.offset.unwrap_or(0); diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 5c60d1a702..50aac1cbaf 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -55,7 +55,7 @@ pub mod user; pub mod workflow; pub use error::{DbError, Result}; -pub use event::{EventQuery, ReactionEventInsertOutcome}; +pub use event::{EventQuery, ReactionEventInsertOutcome, DEFAULT_MAX_PAGE_LIMIT}; use chrono::{DateTime, Utc}; use sqlx::postgres::{PgConnection, PgPoolOptions}; diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 678199e734..a118ff453f 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -3042,6 +3042,27 @@ mod tests { assert_eq!(extract_page_offset(&raw, None), None); } + /// Offsets are sized from the *clamped* limit the DB will honor, not from + /// what the client asked for. `filter_to_query_params` clamps an absent or + /// over-ceiling `limit` to `DEFAULT_MAX_PAGE_LIMIT` (guarded in + /// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`) + /// and that clamped value is what arrives here — so page N starts exactly + /// N-1 full pages in. Sizing from an unclamped limit would step past rows + /// the previous page never returned. + #[test] + fn extract_page_offset_sizes_pages_from_clamped_limit() { + let clamped = buzz_db::DEFAULT_MAX_PAGE_LIMIT; + + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 2 }), Some(clamped)), + Some(clamped) + ); + assert_eq!( + extract_page_offset(&serde_json::json!({ "page": 3 }), Some(clamped)), + Some(clamped * 2) + ); + } + #[test] fn extract_depth_limit_valid() { let raw = serde_json::json!({ "depth_limit": 3 }); diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 35fbf0c892..2aed12cd7f 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -22,7 +22,6 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 2_000; const MAX_SUBSCRIPTIONS: usize = 1024; /// Maximum `query_events` calls in flight per multi-filter REQ / bridge query. @@ -416,10 +415,24 @@ pub async fn handle_req( ); } -/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. -/// Search subscriptions are one-shot — no persistent subscription is registered. +/// FTS candidate hits fetched per page. Pages are always full regardless of +/// the requested limit — post-filtering discards an unpredictable share of +/// hits, so the scan fetches candidates in full pages rather than sizing +/// pages to the request. +const SEARCH_PAGE_SIZE: u32 = 100; + /// Maximum FTS pages to fetch per filter (prevents unbounded loops). -const MAX_SEARCH_PAGES: u32 = 10; +/// +/// Derived from the advertised page ceiling rather than fixed: the scan +/// budget is a resource policy — at most one advertised page ceiling's worth +/// of candidates per filter — and deriving it keeps the budget tracking the +/// ceiling if the ceiling ever moves. This bounds candidates *scanned*, not +/// events *emitted*: post-filtering (NIP-01 match, channel access, reader +/// visibility, dedup) can discard any number of candidates, so a result +/// smaller than the requested limit remains possible and is not a NIP-11 +/// violation — `max_limit` promises a clamp on the request, not a count in +/// the response. +const MAX_SEARCH_PAGES: u32 = (buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32).div_ceil(SEARCH_PAGE_SIZE); /// Resolve request-local channel access, repairing a stale cache-negative. /// @@ -501,6 +514,8 @@ pub(crate) fn build_search_channel_scope_filter( }) } +/// Handle a NIP-50 search REQ: query Postgres FTS, fetch full events, deliver results, EOSE. +/// Search subscriptions are one-shot — no persistent subscription is registered. #[allow(clippy::too_many_arguments)] async fn handle_search_req( sub_id: &str, @@ -535,8 +550,8 @@ async fn handle_search_req( let limit = filter .limit - .map(|l| (l as u32).min(MAX_HISTORICAL_LIMIT as u32)) - .unwrap_or(MAX_HISTORICAL_LIMIT as u32); + .map(|l| (l as u32).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32); if limit == 0 { continue; // NIP-01: limit 0 means "no results from this filter" @@ -583,13 +598,11 @@ async fn handle_search_req( let since = filter.since.map(|s| s.as_secs() as i64); let until = filter.until.map(|u| u.as_secs() as i64); - // Paginate: keep fetching pages until we've emitted `limit` results - // or exhausted the search result set. This ensures post-filtering - // doesn't silently reduce the result count below the requested limit. + // Paginate: keep fetching pages until we've emitted `limit` results or + // exhausted the search result set. Post-filtering discards an unpredictable + // share of each page, so continuing past short yields gives the scan a + // chance — not a guarantee — of filling the requested limit. let mut emitted: u32 = 0; - // Always fetch full pages (100) regardless of limit — post-filtering - // may discard many hits, so we need headroom to fill the requested limit. - let per_page: u32 = 100; for page in 1..=MAX_SEARCH_PAGES { if emitted >= limit { @@ -605,7 +618,7 @@ async fn handle_search_req( since, until, page, - per_page, + per_page: SEARCH_PAGE_SIZE, mode: buzz_search::SearchMode::FullText, }; @@ -617,9 +630,9 @@ async fn handle_search_req( } }; - // A short page is the last page: FTS returns up to `per_page` hits, - // so fewer than that means the result set is exhausted. - let exhausted = search_result.hits.len() < per_page as usize; + // A short page is the last page: FTS returns up to a full page of + // hits, so fewer than that means the result set is exhausted. + let exhausted = search_result.hits.len() < SEARCH_PAGE_SIZE as usize; let page_empty = search_result.hits.is_empty(); let hit_ids: Vec<[u8; 32]> = @@ -878,8 +891,8 @@ fn filter_to_query_params( .and_then(|u| chrono::DateTime::from_timestamp(u.as_secs() as i64, 0)); let limit = filter .limit - .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT)) - .unwrap_or(MAX_HISTORICAL_LIMIT); + .map(|l| (l as i64).min(buzz_db::DEFAULT_MAX_PAGE_LIMIT)) + .unwrap_or(buzz_db::DEFAULT_MAX_PAGE_LIMIT); // Push author filter into SQL. Single-author uses the indexed `pubkey` column; // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. @@ -1418,6 +1431,83 @@ mod tests { ) } + /// NIP-11 `limitation.max_limit` as this relay actually advertises it. + fn advertised_max_limit() -> i64 { + crate::nip11::RelayInfo::build( + None, + None, + false, + crate::config::DEFAULT_MAX_FRAME_BYTES, + None, + ) + .limitation + .expect("limitation") + .max_limit + .expect("max_limit") as i64 + } + + #[test] + fn req_filter_limit_clamps_to_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + + let community = buzz_core::tenant::CommunityId::from_uuid(uuid::Uuid::new_v4()); + + // A filter asking for more than the relay advertises is clamped down to + // exactly the advertised ceiling — the NIP-11 document is the promise, + // this is the enforcement. + let greedy = filter_to_query_params( + &Filter::new().limit(advertised as usize * 10), + None, + community, + ); + assert_eq!(greedy.limit, Some(advertised)); + + // A filter with no `limit` gets the same ceiling, not something larger. + let unbounded = filter_to_query_params(&Filter::new(), None, community); + assert_eq!(unbounded.limit, Some(advertised)); + + // Neither sets `max_limit`, so `query_events` applies its own default + // clamp. That default must equal the advertised value too, or the + // clamp above would be undone one layer down. + assert_eq!(greedy.max_limit, None); + assert_eq!(unbounded.max_limit, None); + assert_eq!(buzz_db::DEFAULT_MAX_PAGE_LIMIT, advertised); + + // Under-ceiling requests are honored verbatim. + let modest = filter_to_query_params(&Filter::new().limit(10), None, community); + assert_eq!(modest.limit, Some(10)); + } + + /// The NIP-50 search path clamps its emission target to the advertised + /// ceiling like every other REQ, but the number of candidates it will scan + /// is bounded a second time by the page budget. This pins the resource + /// policy: the budget covers exactly one advertised page ceiling's worth of + /// candidates — no less (a ceiling raise must not silently shrink the scan + /// relative to what clients may request) and no hand-tuned spare (the budget + /// must stay derived, not drift back into a magic number). It deliberately + /// does NOT claim search fills the emitted limit — post-filtering can + /// discard any number of candidates. + #[test] + fn search_scan_capacity_covers_advertised_nip11_max_limit() { + let advertised = advertised_max_limit(); + let capacity = i64::from(MAX_SEARCH_PAGES) * i64::from(SEARCH_PAGE_SIZE); + + assert!( + capacity >= advertised, + "NIP-50 scans at most {capacity} candidates ({MAX_SEARCH_PAGES} pages of \ + {SEARCH_PAGE_SIZE}) but NIP-11 advertises {advertised} — the scan budget \ + no longer covers the advertised ceiling" + ); + + // The budget is derived, not hand-tuned: one page under the derived + // count must be insufficient, or the ceiling could rise without the + // page count following it. + assert!( + capacity - i64::from(SEARCH_PAGE_SIZE) < advertised, + "scan budget has a spare page of slack — derive it from the ceiling" + ); + } + #[test] fn count_fallback_fetches_one_extra_candidate() { let mut query = diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index a8e397dd21..2575ddd7ba 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -89,6 +89,11 @@ pub struct RelayLimitation { /// Canonical `RelayLimitation` advertised by this relay. /// +/// `max_limit` is [`buzz_db::DEFAULT_MAX_PAGE_LIMIT`], the same constant the +/// REQ path clamps filter limits to, so the advertised ceiling and the +/// enforced one cannot drift (see +/// `handlers::req::tests::req_filter_limit_clamps_to_advertised_nip11_max_limit`). +/// /// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers /// unconditionally reject connections that are not in /// `AuthState::Authenticated`. This is independent of the REST API token @@ -103,7 +108,7 @@ fn relay_limitation(max_message_length: usize) -> RelayLimitation { max_message_length: Some(max_message_length as u64), max_subscriptions: Some(1024), max_filters: Some(10), - max_limit: Some(10_000), + max_limit: Some(buzz_db::DEFAULT_MAX_PAGE_LIMIT as u32), max_subid_length: Some(256), min_pow_difficulty: None, auth_required: true, From ede26863345a518ec46edd6d7692e0281883491b Mon Sep 17 00:00:00 2001 From: Bradley Axen Date: Thu, 30 Jul 2026 15:47:35 -0700 Subject: [PATCH 27/87] fix(desktop): align data deletion labels (#2230) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Profile settings action still says “Sign Out,” while its confirmation action says “Delete My Data.” Both buttons trigger the same destructive local-data wipe and should name it consistently. ## What - Label both destructive actions “Delete my data” - Assert the matching section and confirmation labels in the existing Playwright coverage ## Risk Assessment Low — copy and test assertions only; sign-out behavior is unchanged. ## References - Follow-up to #2208 - #2216 also touches this copy and should preserve “Delete my data” when rebased - `just desktop-check` - `just desktop-test` (3,275 tests) - Desktop E2E build and sign-out Playwright spec (2 tests) Generated with Codex Signed-off-by: Bradley Axen --- desktop/src/features/settings/ui/SignOutSection.tsx | 4 ++-- desktop/tests/e2e/signout-screenshots.spec.ts | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/settings/ui/SignOutSection.tsx b/desktop/src/features/settings/ui/SignOutSection.tsx index 8d4dc1c481..746220459b 100644 --- a/desktop/src/features/settings/ui/SignOutSection.tsx +++ b/desktop/src/features/settings/ui/SignOutSection.tsx @@ -151,7 +151,7 @@ export function SignOutSection() { {isPending ? ( ) : null} - {isPending ? "Signing out…" : "Sign Out"} + {isPending ? "Signing out…" : "Delete my data"}

) : null} - {isPending ? "Signing out…" : "Delete My Data"} + {isPending ? "Signing out…" : "Delete my data"} diff --git a/desktop/tests/e2e/signout-screenshots.spec.ts b/desktop/tests/e2e/signout-screenshots.spec.ts index 32fc3ed09d..5cf35c70ab 100644 --- a/desktop/tests/e2e/signout-screenshots.spec.ts +++ b/desktop/tests/e2e/signout-screenshots.spec.ts @@ -23,7 +23,7 @@ test.describe("signout screenshots", () => { }); }); - test("signout-section — Sign Out card in Settings › Profile", async ({ + test("signout-section — data deletion card in Settings › Profile", async ({ page, }) => { await installMockBridge(page); @@ -32,6 +32,9 @@ test.describe("signout screenshots", () => { const section = page.getByTestId("settings-signout"); await section.scrollIntoViewIfNeeded(); + await expect( + section.getByRole("button", { name: "Delete my data" }), + ).toBeVisible(); // Settle animations before capture. await page.evaluate(() => @@ -59,7 +62,7 @@ test.describe("signout screenshots", () => { await expect(dialog).toBeVisible({ timeout: 5_000 }); await expect(dialog.getByText("Sign out and wipe all data?")).toBeVisible(); await expect( - dialog.getByRole("button", { name: "Delete My Data" }), + dialog.getByRole("button", { name: "Delete my data" }), ).toBeVisible(); // Settle animations before capture. From 9e8fcfda099652926b921bca7fcc9bfecab0e140 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 30 Jul 2026 18:53:04 -0400 Subject: [PATCH 28/87] fix(desktop): channel topic and membership metadata cleanup (#3642) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First slice of #2216, scoped to the system/status lines in the chat timeline. ## Why Two problems on the same surface. **Clearing a channel topic renders as empty quotes.** The relay reports a clear as a `topic_changed` event carrying an empty string — there's no separate "cleared" event type. So the timeline printed: > Alice > changed the topic to “” which reads as if the topic were *set to* two quote marks. Same for purpose. **The membership caption reads like a headline, not a metadata line.** `title` and `action` render on separate lines — the member's name sits in the header row with the avatar and timestamp, and the caption sits beneath it. So the caption was "was added by Alice Chen" standing alone under a name, while its siblings on that same line are "joined the channel" and "left the channel". ## What - Blank, missing, or whitespace-only topic/purpose now reads **"cleared the channel topic"** / **"cleared the channel purpose"**. - Membership captions drop "was": **"added by Alice Chen"**, matching "joined the channel" and "left the channel". - The wording moves to `lib/systemEventCopy.ts` as a pure function, so it's assertable in a unit test instead of only reachable through the DOM. That also removes two JSX fragments from `SystemMessageRow.tsx`, taking it 911 → 900 lines. ## Two E2E assertions this exposed Both were measuring something other than what they claimed, and the copy change tipped them over. Neither is a product bug, but both would have failed the next person too. 1. **`mentions.spec.ts:1245`** asserted a button was un-underlined while the mouse was still parked from a previous `hover()`. Any reflow — new rows, scroll-to-bottom, a different text wrap — can slide that button under the stationary pointer, so the assertion measured *where the mouse happened to be* rather than the resting style. Dropping four characters changed the text wrap, changed the row height, changed the scroll offset, and the pointer landed on it. Now parks the pointer off-target first. 2. **`mentions.spec.ts:1253`** used a bare `role=tooltip` lookup. Once the first tooltip animates out while the second opens, two elements match and strict mode trips. Now scopes to the open tooltip via `:not([data-state="closed"])`. ## Deliberately out of scope - **Timestamps.** The day divider, per-message clock times, the Inbox thread pane, and the inbox list have three divergent date implementations and none fully match the writing standard's Today/Yesterday/weekday/date progression. That's its own slice of #2216. - **Whose avatar shows.** An addition puts the *added* member in the header; a removal puts the *remover* there. Possibly intentional, but it's a design question, not copy. - **`the channel` vs `this channel`.** joined/left/removed say "the channel"; created/archived/unarchived say "this channel". Worth normalizing, but it touches lines this PR otherwise leaves alone. ## Validation - `pnpm check`, `pnpm typecheck` — clean - Unit: **3781/3781**, including 6 new tests in `systemEventCopy.test.mjs` covering set/blank/undefined/null/whitespace for both fields, plus a guard that no variant can emit empty quotes - Smoke E2E `mentions` + `messaging`: **85/85** - The previously fragile test run with `--repeat-each=5`: **5/5** Signed-off-by: Clay Delk Co-authored-by: Claude Opus 5 (1M context) --- .../messages/lib/systemEventCopy.test.mjs | 107 ++++++++++++++++++ .../features/messages/lib/systemEventCopy.ts | 59 ++++++++++ .../features/messages/ui/SystemMessageRow.tsx | 56 +++++++-- desktop/tests/e2e/mentions.spec.ts | 26 +++-- .../channel_detail_page/system_rows.dart | 7 +- .../features/channels/timeline_message.dart | 24 +++- .../channels/channel_detail_page_test.dart | 6 +- .../channels/timeline_message_test.dart | 47 ++++++++ 8 files changed, 311 insertions(+), 21 deletions(-) create mode 100644 desktop/src/features/messages/lib/systemEventCopy.test.mjs create mode 100644 desktop/src/features/messages/lib/systemEventCopy.ts diff --git a/desktop/src/features/messages/lib/systemEventCopy.test.mjs b/desktop/src/features/messages/lib/systemEventCopy.test.mjs new file mode 100644 index 0000000000..eeed9d543c --- /dev/null +++ b/desktop/src/features/messages/lib/systemEventCopy.test.mjs @@ -0,0 +1,107 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + describeChannelTextFieldChange, + toInlineName, +} from "./systemEventCopy.ts"; + +test("a set topic is quoted verbatim", () => { + assert.equal( + describeChannelTextFieldChange("topic", "Release planning"), + "changed the topic to “Release planning”", + ); +}); + +test("a set purpose names the purpose, not the topic", () => { + assert.equal( + describeChannelTextFieldChange("purpose", "Where we ship from"), + "changed the purpose to “Where we ship from”", + ); +}); + +// The relay reports a clear as a change carrying an empty string, so without +// this branch the timeline reads: changed the topic to “”. +test("an empty value reads as cleared, not as a change to empty quotes", () => { + for (const blank of ["", undefined, null]) { + assert.equal( + describeChannelTextFieldChange("topic", blank), + "cleared the topic", + ); + assert.equal( + describeChannelTextFieldChange("purpose", blank), + "cleared the purpose", + ); + } +}); + +test("a whitespace-only value reads as cleared", () => { + assert.equal( + describeChannelTextFieldChange("topic", " \n\t "), + "cleared the topic", + ); +}); + +test("surrounding whitespace is trimmed out of the quotes", () => { + assert.equal( + describeChannelTextFieldChange("topic", " Release planning "), + "changed the topic to “Release planning”", + ); +}); + +test("no caption announces empty quotes", () => { + for (const value of ["", " ", null, undefined, "Real topic"]) { + for (const field of ["topic", "purpose"]) { + assert.doesNotMatch( + describeChannelTextFieldChange(field, value), + /“”|""/, + `${field} with ${JSON.stringify(value)} must not render empty quotes`, + ); + } + } +}); + +test("the reader's own name is lowercase mid-sentence", () => { + // "added by You" next to an agent's "managed by you" was the inconsistency. + assert.equal(toInlineName("You", true), "you"); +}); + +test("cleared and changed captions use the same noun", () => { + // Not "cleared the channel topic" against "changed the topic to …". + assert.match(describeChannelTextFieldChange("topic", ""), /\bthe topic\b/); + assert.match( + describeChannelTextFieldChange("topic", "Ship it"), + /\bthe topic\b/, + ); + for (const value of ["", "Ship it"]) { + assert.doesNotMatch( + describeChannelTextFieldChange("topic", value), + /channel topic/, + ); + } +}); + +test("every other name keeps its own capitalization", () => { + for (const name of [ + "Alice Chen", + "you-know-who", + "Someone", + "npub1abc…def", + ]) { + assert.equal(toInlineName(name, false), name); + } +}); + +test("someone else whose display name is literally You is left alone", () => { + // The decisive case: the label is user-controlled, identity is not. Matching + // on the string would rewrite this person's name as if they were the reader. + assert.equal(toInlineName("You", false), "You"); + assert.equal(toInlineName("Youssef", false), "Youssef"); + assert.equal(toInlineName("You Know Who", false), "You Know Who"); +}); + +test("the reader is lowercased whatever their profile name says", () => { + // Self resolution never consults the profile, but the rule keys on identity, + // so it does not matter what the label happens to be. + assert.equal(toInlineName("Alice Chen", true), "you"); +}); diff --git a/desktop/src/features/messages/lib/systemEventCopy.ts b/desktop/src/features/messages/lib/systemEventCopy.ts new file mode 100644 index 0000000000..bae6abb09b --- /dev/null +++ b/desktop/src/features/messages/lib/systemEventCopy.ts @@ -0,0 +1,59 @@ +/** + * Copy for channel system events (the "joined", "added by", "changed the + * topic" captions in the message timeline). + * + * These live outside `SystemMessageRow` so the wording is a pure function of + * the payload and can be asserted directly in tests. Only cases whose caption + * is plain text belong here — cases that interpolate a profile link build their + * JSX in the component. + */ + +/** Curly quotes, so the caption matches the typography used elsewhere in chat. */ +const OPEN_QUOTE = "“"; +const CLOSE_QUOTE = "”"; + +export type ChannelTextField = "topic" | "purpose"; + +/** + * Caption for a channel topic or purpose change. + * + * Bare "the topic" rather than "the channel topic": this row only ever renders + * in a channel timeline, under that channel's own header, so naming the channel + * again is redundant — and it keeps the cleared and changed captions on the same + * noun instead of one saying "channel topic" and the other "topic". + * + * A blank value means the field was cleared: the relay reports a clear as a + * `topic_changed` / `purpose_changed` event carrying an empty string, not as a + * separate event type. Without this branch the timeline renders `changed the + * topic to ""`, which reads like the topic was set to two quote marks. + * Whitespace-only values are treated as cleared for the same reason. + */ +export function describeChannelTextFieldChange( + field: ChannelTextField, + value: string | null | undefined, +): string { + const trimmed = value?.trim(); + if (!trimmed) { + return `cleared the ${field}`; + } + return `changed the ${field} to ${OPEN_QUOTE}${trimmed}${CLOSE_QUOTE}`; +} + +/** + * Adjusts a resolved display name for use inside a sentence rather than in the + * name slot at the top of a row — "added by you", "removed you from the channel". + * + * `resolveUserLabel` returns "You" for the current user, which is right standing + * alone and wrong mid-phrase. Agent ownership already draws the same distinction + * from the other side: `formatOwnerLabel` returns lowercase "you" because it is + * only ever read as "managed by you". + * + * `isSelf` is the caller's pubkey comparison, not an inspection of `label`. + * Matching on the string would also rewrite a different person whose display + * name happens to be "You" — the label is user-controlled, identity is not. + * Every name that isn't the reader's own is a proper noun and is returned + * untouched. + */ +export function toInlineName(label: string, isSelf: boolean): string { + return isSelf ? "you" : label; +} diff --git a/desktop/src/features/messages/ui/SystemMessageRow.tsx b/desktop/src/features/messages/ui/SystemMessageRow.tsx index 4410964dd9..c4637d2823 100644 --- a/desktop/src/features/messages/ui/SystemMessageRow.tsx +++ b/desktop/src/features/messages/ui/SystemMessageRow.tsx @@ -28,6 +28,10 @@ import { import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; import { UserAvatar } from "@/shared/ui/UserAvatar"; +import { + describeChannelTextFieldChange, + toInlineName, +} from "../lib/systemEventCopy"; import { MessageAgentOwner } from "./MessageAgentOwner"; import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; @@ -180,6 +184,29 @@ function resolveDisplayLabel( return resolveLabel(pubkey, currentPubkey, profiles); } +function isSelfPubkey( + pubkey: string | undefined, + currentPubkey: string | undefined, +): boolean { + return Boolean( + pubkey && + currentPubkey && + normalizePubkey(pubkey) === normalizePubkey(currentPubkey), + ); +} + +/** Same label as `resolveDisplayLabel`, adjusted for mid-sentence use. */ +function resolveInlineDisplayLabel( + pubkey: string | undefined, + currentPubkey: string | undefined, + profiles: UserProfileLookup | undefined, +): string { + return toInlineName( + resolveLabel(pubkey, currentPubkey, profiles), + isSelfPubkey(pubkey, currentPubkey), + ); +} + function isKnownAgentPubkey( pubkey: string | undefined, profiles: UserProfileLookup | undefined, @@ -386,7 +413,7 @@ function MembershipPersonName({ pubkey={pubkey} underlineOnHover > - {resolveDisplayLabel(pubkey, currentPubkey, profiles)} + {resolveInlineDisplayLabel(pubkey, currentPubkey, profiles)} ); } @@ -497,12 +524,17 @@ function describeSystemEvent( currentPubkey, profiles, ); + const inlineTargetLabel = resolveInlineDisplayLabel( + payload.target, + currentPubkey, + profiles, + ); const actorName = ( {actorLabel} ); const targetName = ( - {targetLabel} + {inlineTargetLabel} ); const membershipTitle = ( @@ -522,9 +554,13 @@ function describeSystemEvent( title: membershipTitle, action: ( <> - was added by{" "} + added by{" "} - {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + {resolveInlineDisplayLabel( + payload.actor, + currentPubkey, + profiles, + )} , along with{" "} - was added by{" "} + added by{" "} - {resolveDisplayLabel(payload.actor, currentPubkey, profiles)} + {resolveInlineDisplayLabel( + payload.actor, + currentPubkey, + profiles, + )} ), @@ -587,12 +627,12 @@ function describeSystemEvent( case "topic_changed": return { title: actorName, - action: <>changed the topic to “{payload.topic}”, + action: describeChannelTextFieldChange("topic", payload.topic), }; case "purpose_changed": return { title: actorName, - action: <>changed the purpose to “{payload.purpose}”, + action: describeChannelTextFieldChange("purpose", payload.purpose), }; case "channel_created": return { diff --git a/desktop/tests/e2e/mentions.spec.ts b/desktop/tests/e2e/mentions.spec.ts index 694b5abef5..512eb3d800 100644 --- a/desktop/tests/e2e/mentions.spec.ts +++ b/desktop/tests/e2e/mentions.spec.ts @@ -1114,7 +1114,7 @@ test("system add rows use plain names while remove rows retain agent mention sty const addedRow = page .getByTestId("system-message-row") .filter({ hasText: "portal" }) - .filter({ hasText: "was added by" }); + .filter({ hasText: "added by" }); const removedRow = page .getByTestId("system-message-row") .filter({ hasText: "removed portal from the channel" }); @@ -1178,7 +1178,7 @@ test("groups member additions and joins with hidden names in the standard toolti const groupedRow = page .getByTestId("system-message-row") - .filter({ hasText: "was added by Alice Chen" }); + .filter({ hasText: "added by Alice Chen" }); for (const visibleName of [ "Erica Chapman", "Peter Griffin", @@ -1188,9 +1188,9 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(groupedRow).toContainText(visibleName); } await expect( - groupedRow.locator("p").filter({ hasText: "was added by" }), + groupedRow.locator("p").filter({ hasText: "added by" }), ).toContainText( - "was added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", + "added by Alice Chen, along with Peter Griffin, Marcia Thomas, Jordan Lee, and 2 others", ); await expect(groupedRow.locator("[data-mention]")).toHaveCount(0); @@ -1200,6 +1200,11 @@ test("groups member additions and joins with hidden names in the standard toolti await expect(visibleName).toHaveCSS("text-decoration-line", "underline"); const othersTrigger = groupedRow.getByRole("button", { name: "2 others" }); + // Park the pointer off-target first: the previous hover leaves the mouse at a + // fixed viewport point, and any later reflow (new rows, scroll-to-bottom, a + // different text wrap) can slide this button under it. Without this the + // assertion measures where the mouse happens to be, not the resting style. + await page.mouse.move(0, 0); await expect(othersTrigger).toHaveCSS("text-decoration-line", "none"); await othersTrigger.hover(); await expect(othersTrigger).toHaveCSS("text-decoration-line", "underline"); @@ -1242,10 +1247,17 @@ test("groups member additions and joins with hidden names in the standard toolti const joinedOthersTrigger = joinedRow.getByRole("button", { name: "2 others", }); + await page.mouse.move(0, 0); await expect(joinedOthersTrigger).toHaveCSS("text-decoration-line", "none"); await joinedOthersTrigger.hover(); - await expect(page.getByRole("tooltip")).toContainText("Olivia Park"); - await expect(page.getByRole("tooltip")).toContainText("Sam Rivera"); + // Scope to the *open* tooltip: the first row's tooltip stays mounted with + // data-state="closed" while it animates out, so a bare role=tooltip lookup + // matches two elements and trips strict mode. + const joinedTooltip = page.locator( + '[role="tooltip"]:not([data-state="closed"])', + ); + await expect(joinedTooltip).toContainText("Olivia Park"); + await expect(joinedTooltip).toContainText("Sam Rivera"); }); test("system agent profile only exposes message action", async ({ page }) => { @@ -1277,7 +1289,7 @@ test("system agent profile only exposes message action", async ({ page }) => { const joinedRow = page .getByTestId("system-message-row") .filter({ hasText: "mira" }) - .filter({ hasText: "was added by" }); + .filter({ hasText: "added by" }); const agentName = joinedRow.getByText("mira", { exact: true }); await expect(agentName).toHaveText("mira"); await expect(agentName).not.toHaveAttribute("data-mention"); diff --git a/mobile/lib/features/channels/channel_detail_page/system_rows.dart b/mobile/lib/features/channels/channel_detail_page/system_rows.dart index 72554ee1a1..0372690e1e 100644 --- a/mobile/lib/features/channels/channel_detail_page/system_rows.dart +++ b/mobile/lib/features/channels/channel_detail_page/system_rows.dart @@ -279,7 +279,12 @@ class _MembershipSystemMessageContent extends StatelessWidget { TextSpan( text: event.isSelfJoin ? 'joined the channel' - : 'was added by ${resolveLabel(event.actorPubkey)}', + // No "was": the name renders on the line above via + // MessageAuthorMeta, so this reads as a status line rather than a + // sentence continuing across the metadata row. Matches desktop's + // SystemMessageRow. `SystemEvent.describe` keeps "was added by" + // because it builds subject and predicate into one string. + : 'added by ${resolveLabel(event.actorPubkey)}', ), if (additionalTargets.isNotEmpty) TextSpan(text: event.isSelfJoin ? ' along with ' : ', along with '), diff --git a/mobile/lib/features/channels/timeline_message.dart b/mobile/lib/features/channels/timeline_message.dart index 253c949703..c8fd96491b 100644 --- a/mobile/lib/features/channels/timeline_message.dart +++ b/mobile/lib/features/channels/timeline_message.dart @@ -101,9 +101,10 @@ class SystemEvent { final target = resolveLabel(targetPubkey); return '$actor removed $target from the channel'; }(), - SystemEventType.topicChanged => '$actor changed the topic to "$topic"', + SystemEventType.topicChanged => + '$actor ${_describeTextFieldChange('topic', topic)}', SystemEventType.purposeChanged => - '$actor changed the purpose to "$purpose"', + '$actor ${_describeTextFieldChange('purpose', purpose)}', SystemEventType.channelCreated => '$actor created this channel', SystemEventType.channelArchived => '$actor archived this channel', SystemEventType.channelUnarchived => '$actor unarchived this channel', @@ -113,6 +114,25 @@ class SystemEvent { } } +/// Caption fragment for a channel topic or purpose change, e.g. +/// `changed the topic to "Release planning"` or `cleared the topic`. +/// +/// A blank value means the field was cleared: the relay reports a clear as a +/// `topic_changed` / `purpose_changed` event carrying an empty string, not as a +/// separate event type. Without this branch the timeline renders +/// `changed the topic to ""`, which reads as if the topic were set to two quote +/// marks. Whitespace-only values are treated as cleared for the same reason. +/// +/// Mirrors `describeChannelTextFieldChange` in +/// `desktop/src/features/messages/lib/systemEventCopy.ts`. +String _describeTextFieldChange(String field, String? value) { + final trimmed = value?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return 'cleared the $field'; + } + return 'changed the $field to "$trimmed"'; +} + @immutable class TimelineReaction { final String emoji; diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index 1c66093899..899394de23 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -1348,7 +1348,7 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Bob'), findsOneWidget); - final addedAction = findRichText('was added by Alice'); + final addedAction = findRichText('added by Alice'); expect(addedAction, findsOneWidget); expect(find.text('Alice added Bob to the channel'), findsNothing); expect( @@ -1366,7 +1366,7 @@ void main() { expect(timestampRect.left, greaterThan(nameRect.right)); final addedText = tester.widget(addedAction); expect( - effectiveFontSizeForText(addedText.text, 'was added by Alice'), + effectiveFontSizeForText(addedText.text, 'added by Alice'), systemMessageBodyTextStyle.fontSize, ); }); @@ -1435,7 +1435,7 @@ void main() { expect(find.text('Bob'), findsOneWidget); expect( - findRichText('was added by Alice, along with Carol, Dave, Erin, and '), + findRichText('added by Alice, along with Carol, Dave, Erin, and '), findsOneWidget, ); expect(find.byKey(const Key('membership-overflow')), findsOneWidget); diff --git a/mobile/test/features/channels/timeline_message_test.dart b/mobile/test/features/channels/timeline_message_test.dart index 87f39d1c65..c29a12aef4 100644 --- a/mobile/test/features/channels/timeline_message_test.dart +++ b/mobile/test/features/channels/timeline_message_test.dart @@ -287,6 +287,53 @@ void main() { ); }); + // The relay reports a clear as a change carrying an empty string, so + // without the cleared branch this reads: changed the topic to "". + test('a blank topic or purpose reads as cleared', () { + for (final blank in [null, '', ' \n\t ']) { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: blank, + ).describe(resolve), + 'Alice cleared the topic', + ); + expect( + SystemEvent( + type: SystemEventType.purposeChanged, + actorPubkey: 'pk1', + purpose: blank, + ).describe(resolve), + 'Alice cleared the purpose', + ); + } + }); + + test('no caption announces empty quotes', () { + for (final value in [null, '', ' ', 'Real topic']) { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: value, + ).describe(resolve), + isNot(contains('""')), + ); + } + }); + + test('surrounding whitespace is trimmed out of the quotes', () { + expect( + SystemEvent( + type: SystemEventType.topicChanged, + actorPubkey: 'pk1', + topic: ' Release v2 ', + ).describe(resolve), + 'Alice changed the topic to "Release v2"', + ); + }); + test('channel_created', () { final event = SystemEvent( type: SystemEventType.channelCreated, From f3e5e812677f6f14bffe16a7aa02642d56faca4b Mon Sep 17 00:00:00 2001 From: Alex Kemper Date: Thu, 30 Jul 2026 18:54:41 -0400 Subject: [PATCH 29/87] fix(catalog): update Amp tagline (#3806) ## Summary Update Amp's runtime catalog description to use its current tagline: > The coding agent and development environment that runs anywhere and everywhere. ### Related issue N/A. This follows the Amp description update in https://github.com/block/buzz/pull/3758. ### Testing * `pnpm -C desktop check` * `pnpm -C desktop typecheck` * `pnpm -C desktop test` (3,835 passed) No screenshot is included because this changes only the catalog description text. It does not change layout or interaction behavior. Signed-off-by: AJKemps Co-authored-by: AJKemps Co-authored-by: Alex Kemper --- desktop/src/features/settings/ui/harnessCatalogCopy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/features/settings/ui/harnessCatalogCopy.ts b/desktop/src/features/settings/ui/harnessCatalogCopy.ts index 70439091b2..9a71ff70f9 100644 --- a/desktop/src/features/settings/ui/harnessCatalogCopy.ts +++ b/desktop/src/features/settings/ui/harnessCatalogCopy.ts @@ -36,7 +36,7 @@ const HARNESS_DESCRIPTIONS: Record = { // https://moonshotai.github.io/kimi-cli/en/ kimi: "A terminal coding agent for software development and command-line tasks.", // Sources: https://ampcode.com, https://ampcode.com/manual - amp: "A coding agent for your terminal and editor.", + amp: "The coding agent and development environment that runs anywhere and everywhere.", // Sources: https://github.com/NousResearch/hermes-agent, // https://hermes-agent.nousresearch.com/docs/ hermes: "A general-purpose AI agent from Nous Research.", From 468647a51f858b29d27eaf9fd07bf90294f99d39 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Thu, 30 Jul 2026 20:58:03 -0400 Subject: [PATCH 30/87] feat(desktop): locally stored NIP-49 encrypted key backup (#2937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds a locally stored **NIP-49 encrypted key backup** (`ncryptsec`) to the desktop app, per the plan reviewed in buzz-development (Rev 3, approved 9/10 by Wren; implementation also reviewed and approved 9/10). **Two-artifact design — canonical bytes originate entirely in Rust:** - `create_ncryptsec_backup` runs under the `identity_mutation` lock: encrypt → decrypt-verify against the live pubkey → atomic `0o600` write to `{app_data_dir}/identity.ncryptsec` → reread/byte-compare → return the exact persisted bytes. The frontend never re-derives or re-encrypts. - `save_ncryptsec_copy` writes a portable copy via the save dialog (parse-gated, secret-file semantics) and never mutates canonical state. - `generate_backup_passphrase`: 6 words from the EFF short wordlist via `OsRng` (custom passphrases min 12 chars). - Import accepts `ncryptsec1` with optional password; the raw-`nsec` path is untouched. Different-pubkey import and sign-out wipe the app-managed backup (post-commit, best-effort — a failed import can never destroy the still-live identity's backup; regression-tested). **Never-relay guarantee (egress guard + tripwires):** - `egress_guard.rs` fail-closed at all 8 `/events` submission boundaries (relay submit funnel, 3× `relay.rs`, huddle STT, both engram submitters, native WS choke point), rejecting `ncryptsec1`/`NCRYPTSEC1` in text and binary frames. Scope is deliberately ncryptsec-only: pairing intentionally carries raw nsec inside its encrypted session. - Site-granular `/events` inventory tripwire: per-file (`/events` count, guard-call count) pairs; unlisted files expect zero. Mutation-style tests prove a ninth site in an existing file, a removed guard, and a new unlisted file all fail the scan. - ncryptsec source-allowlist scans in **both** trees (Rust + TS). **Frontend:** onboarding `BackupStep` is encrypted-by-default — the default path never invokes `get_nsec` (e2e asserts the command log). Raw-nsec export stays behind an explicit click with prior semantics. Shared `EncryptedBackupCreator` powers onboarding + a new settings row; the import form auto-switches to encrypted mode on `ncryptsec1` paste (case-insensitive HRP). **Open product call for @tlongwell-block:** onboarding default is *encrypted* in this PR; flipping to raw-default is a small change either way (documented in the plan). Review history: plan Rev 3 and the implementation were both iterated with Wren to 9/10 (two blockers from round 1 — import ordering, inventory granularity — plus an uppercase-bech32 hardening gap, all fixed in `dde37183e`). Thread: buzz-development. ### Related issue Follow-up to the direction explored in #385 (NIP-PB, closed) — this ships local NIP-49 (the standard) instead of a new NIP. No open duplicate found. ### Testing All at exactly `dde37183e` (same shell, HEAD verified): - `cargo test` — 1680 passed / 0 failed / 14 ignored (includes a deliberate ~70s log_n-18 NIP-49 round trip, spec vector, wrong-password, NFKC, uppercase-vector decrypt, injection test per egress boundary, inventory mutation tests, import-ordering regression tests) - `cargo clippy --all-targets -- -D warnings` — clean; `cargo fmt --check` — clean - `pnpm typecheck` — clean; JS unit suite 3529/3529; biome (repo-pinned 2.4.16) clean - Playwright `onboarding-backup` / `onboarding` / `onboarding-agent-defaults` / `profile-nsec-reveal` — 86 passed, 1 known avatar-reservation flake (passed on rerun; untouched by this diff). `passThroughBackupStep` now exercises the encrypted default, so every downstream onboarding spec covers the new path. - Note: browser e2e fakes the crypto via the mock bridge (fixed spec-vector blob); decryption correctness is proven in the Rust tests. ## Latest onboarding integration The current head adds an additive `IdentityInfo.storage` field (`ephemeral`, `system-keyring`, `local-file`, or `environment`) so onboarding can accurately explain where the active identity is protected. It surfaces storage metadata only—never key material—and leaves the existing lost/keyring-locked recovery behavior intact. --------- Signed-off-by: Tyler Longwell Signed-off-by: Taylor Ho Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell Co-authored-by: Taylor Ho Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> --- .../texture-card/generate-card-texture.mjs | 159 ++-- desktop/src-tauri/src/app_state.rs | 97 +- desktop/src-tauri/src/app_state_tests.rs | 12 +- desktop/src-tauri/src/commands/identity.rs | 117 ++- desktop/src-tauri/src/identity_storage.rs | 62 ++ desktop/src-tauri/src/key_backup.rs | 47 + desktop/src-tauri/src/key_backup_tests.rs | 79 +- desktop/src-tauri/src/lib.rs | 1 + desktop/src-tauri/src/models.rs | 2 + desktop/src-tauri/src/reset.rs | 20 + .../onboarding/lib/encryptedBackup.test.mjs | 118 +++ .../onboarding/lib/encryptedBackup.ts | 135 +++ .../onboarding/lib/keyImportInput.test.mjs | 73 ++ .../features/onboarding/lib/keyImportInput.ts | 127 +++ .../onboarding/ui/BackupPasswordTimeline.tsx | 130 +++ .../src/features/onboarding/ui/BackupStep.tsx | 495 +++++++--- .../features/onboarding/ui/BackupTestFlow.tsx | 745 +++++++++++++++ .../onboarding/ui/DownloadKeyStep.tsx | 139 +++ .../onboarding/ui/EncryptedBackupCreator.tsx | 885 ++++++++++++++++++ .../onboarding/ui/KeyringLockedScreen.tsx | 4 +- .../onboarding/ui/MachineOnboardingFlow.tsx | 158 +++- .../onboarding/ui/NostrKeyImportForm.tsx | 561 ++++++----- .../onboarding/ui/OnboardingChrome.tsx | 15 +- .../features/onboarding/ui/OnboardingFlow.tsx | 4 +- .../ui/OnboardingSlideTransition.tsx | 1 + .../src/features/onboarding/ui/SetupStep.tsx | 41 +- .../ui/onboardingFlowSteps.test.mjs | 24 +- .../features/settings/ui/SignOutSection.tsx | 38 +- desktop/src/shared/api/identityTypes.ts | 28 + desktop/src/shared/api/tauriIdentity.ts | 11 +- desktop/src/shared/api/types.ts | 20 +- .../shared/lib/ncryptsecSourceScan.test.mjs | 74 ++ .../src/shared/styles/globals/components.css | 85 +- desktop/src/shared/ui/alert-dialog.tsx | 69 +- .../shared/ui/assets/card-texture-compact.png | Bin 0 -> 232377 bytes .../ui/assets/card-texture-dark-compact.png | Bin 0 -> 328178 bytes .../shared/ui/assets/card-texture-dark.png | Bin 0 -> 1678571 bytes desktop/src/shared/ui/card-texture.css | 47 +- desktop/src/shared/ui/card.tsx | 46 +- desktop/src/shared/ui/popover.tsx | 73 +- desktop/src/testing/e2eBridge.ts | 65 +- desktop/tests/e2e/harness-management.spec.ts | 9 +- desktop/tests/e2e/onboarding-backup.spec.ts | 333 ++++++- .../onboarding-docked-cta-screenshots.spec.ts | 99 +- desktop/tests/e2e/onboarding.spec.ts | 125 +++ .../tests/e2e/signout-confirmation.spec.ts | 34 +- desktop/tests/helpers/fileDrag.ts | 52 + desktop/tests/helpers/onboarding.ts | 3 +- 48 files changed, 4728 insertions(+), 734 deletions(-) create mode 100644 desktop/src-tauri/src/identity_storage.rs create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.test.mjs create mode 100644 desktop/src/features/onboarding/lib/encryptedBackup.ts create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.test.mjs create mode 100644 desktop/src/features/onboarding/lib/keyImportInput.ts create mode 100644 desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx create mode 100644 desktop/src/features/onboarding/ui/BackupTestFlow.tsx create mode 100644 desktop/src/features/onboarding/ui/DownloadKeyStep.tsx create mode 100644 desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx create mode 100644 desktop/src/shared/api/identityTypes.ts create mode 100644 desktop/src/shared/lib/ncryptsecSourceScan.test.mjs create mode 100644 desktop/src/shared/ui/assets/card-texture-compact.png create mode 100644 desktop/src/shared/ui/assets/card-texture-dark-compact.png create mode 100644 desktop/src/shared/ui/assets/card-texture-dark.png create mode 100644 desktop/tests/helpers/fileDrag.ts diff --git a/desktop/scripts/texture-card/generate-card-texture.mjs b/desktop/scripts/texture-card/generate-card-texture.mjs index 75cc24e744..57ebc61a9b 100644 --- a/desktop/scripts/texture-card/generate-card-texture.mjs +++ b/desktop/scripts/texture-card/generate-card-texture.mjs @@ -12,83 +12,116 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const HERE = path.dirname(fileURLToPath(import.meta.url)); -const OUTPUT = path.resolve( - HERE, - "../../src/shared/ui/assets/card-texture.png", -); - -// CSS-pixel source geometry. Screenshotting at DPR 2 produces a crisp asset. -const CARD_SIZE = 640; -const OUTSET = 96; -const CAPTURE_SIZE = CARD_SIZE + OUTSET * 2; +const OUTPUT_DIRECTORY = path.resolve(HERE, "../../src/shared/ui/assets"); const DPR = 2; // Approved texture parameters, archived from the former runtime SVG filter. -const BLUR = 66; -const DILATE = Math.round(BLUR * 0.85); const THRESHOLD_BIAS = 0.302; const SLOPE = 8; const FREQUENCY = 0.999; const OCTAVES = 3; const SEED = 5315; -await mkdir(path.dirname(OUTPUT), { recursive: true }); +const TEXTURES = [ + { + filename: "card-texture.png", + color: "white", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-dark.png", + color: "#171b21", + cardSize: 640, + outset: 96, + blur: 66, + innerBand: 112, + }, + { + filename: "card-texture-compact.png", + color: "white", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, + { + filename: "card-texture-dark-compact.png", + color: "#171b21", + cardSize: 320, + outset: 24, + blur: 24, + innerBand: 44, + }, +]; + +await mkdir(OUTPUT_DIRECTORY, { recursive: true }); const browser = await chromium.launch(); try { - const page = await browser.newPage({ - deviceScaleFactor: DPR, - viewport: { height: CAPTURE_SIZE, width: CAPTURE_SIZE }, - }); + for (const texture of TEXTURES) { + const captureSize = texture.cardSize + texture.outset * 2; + const dilate = Math.round(texture.blur * 0.85); + const output = path.join(OUTPUT_DIRECTORY, texture.filename); + const page = await browser.newPage({ + deviceScaleFactor: DPR, + viewport: { height: captureSize, width: captureSize }, + }); - await page.setContent(` - -
- - -
`); + await page.setContent(` + +
+ + +
`); - await page.locator("#stage").screenshot({ - omitBackground: true, - path: OUTPUT, - }); + await page.locator("#stage").screenshot({ + omitBackground: true, + path: output, + }); + await page.close(); + + console.log(`Generated ${output}`); + console.log(`Asset: ${captureSize * DPR}×${captureSize * DPR}px @${DPR}x`); + console.log( + `Runtime slice: ${(texture.outset + texture.innerBand) * DPR}px; outset: ${texture.outset}px`, + ); + } } finally { await browser.close(); } - -console.log(`Generated ${OUTPUT}`); -console.log(`Asset: ${CAPTURE_SIZE * DPR}×${CAPTURE_SIZE * DPR}px @${DPR}x`); -console.log(`Runtime slice: ${(OUTSET + 112) * DPR}px; outset: ${OUTSET}px`); diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 94d162e620..abce86202a 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -2,7 +2,7 @@ use std::{ collections::HashMap, io::Write, sync::{ - atomic::{AtomicBool, AtomicU16}, + atomic::{AtomicBool, AtomicU16, AtomicU8}, Arc, Mutex, }, }; @@ -13,10 +13,15 @@ use tauri::{AppHandle, Manager}; use tokio::sync::Mutex as AsyncMutex; use crate::huddle::HuddleState; +pub(crate) use crate::identity_storage::{IdentityStorage, RecoveryState, ResolvedIdentity}; use crate::managed_agents::config_bridge::SessionConfigCache; use crate::managed_agents::{ManagedAgentPairRuntime, ManagedAgentRuntimeKey}; + pub struct AppState { pub keys: Mutex, + /// Durable backend holding `keys`. Updated after the key write and before + /// recovery flags are cleared so `get_identity` reports a consistent state. + pub(crate) identity_storage: AtomicU8, pub http_client: reqwest::Client, /// A no-redirect client for authenticated relay media fetches (download, /// clipboard copy, snapshot, editor). Every caller pre-validates the URL @@ -178,19 +183,20 @@ pub fn build_media_fetch_client() -> reqwest::Result { pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. - let keys = match identity_from_env() { + let (keys, identity_storage) = match identity_from_env() { Some(keys) => { eprintln!( "buzz-desktop: configured identity pubkey {}", keys.public_key().to_hex() ); - keys + (keys, IdentityStorage::Environment) } - None => Keys::generate(), + None => (Keys::generate(), IdentityStorage::Ephemeral), }; AppState { keys: Mutex::new(keys), + identity_storage: AtomicU8::new(identity_storage as u8), http_client: reqwest::Client::builder() .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) .pool_idle_timeout(std::time::Duration::from_secs(10)) @@ -366,9 +372,13 @@ pub fn resolve_persisted_identity(app: &AppHandle, state: &AppState) -> Result<( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let resolved = load_or_create_identity(&data_dir)?; - // Write keys before setting the recovery flags (Release) so any thread - // that reads a flag as false with Acquire is guaranteed to see the keys. - *state.keys.lock().map_err(|e| e.to_string())? = resolved.keys; + // Write keys and storage before setting the recovery flags (Release) so + // any thread that reads a flag as false with Acquire sees consistent data. + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = resolved.keys; + state.set_identity_storage(resolved.storage); + } state.identity_lost.store( resolved.recovery == RecoveryState::Lost, std::sync::atomic::Ordering::Release, @@ -394,26 +404,6 @@ const IDENTITY_KEY_NAME: &str = "identity"; /// keyring is merely unreachable (the key IS in the keyring, must NOT generate). const MIGRATION_MARKER_NAME: &str = "identity.migrated"; -/// Recovery state produced by identity resolution. `None` means the app has -/// a real, usable identity. `Lost` means the keyring was reachable-but-empty -/// despite a prior successful migration — the key vanished externally. `KeyringLocked` -/// means the keyring is unreachable this boot but was used in the past -/// (marker present, no file) — the key still exists but is temporarily -/// inaccessible. Both non-`None` variants boot with an ephemeral key; the -/// frontend shows a different recovery screen for each. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RecoveryState { - None, - Lost, - KeyringLocked, -} - -/// The output of identity resolution. -struct ResolvedIdentity { - keys: Keys, - recovery: RecoveryState, -} - /// The keyring operations the identity resolution flow needs. Abstracted so the /// corrupt-keyring recovery decision ([`recover_from_keyring`]) can be /// unit-tested against a fake without touching the live OS keyring. @@ -465,6 +455,7 @@ fn load_or_create_identity(data_dir: &std::path::Path) -> Result Result<(), String> { +) -> Result { match persist_identity_to_keyring(store, keys, legacy_path, data_dir) { - Ok(()) => Ok(()), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(e) => { eprintln!( "buzz-desktop: keyring write failed during import ({e}), \ falling back to identity.key" ); - save_key_file(legacy_path, keys) + save_key_file(legacy_path, keys)?; + Ok(IdentityStorage::LocalFile) } } } @@ -892,7 +897,7 @@ pub(crate) fn persist_imported_identity( keys: &Keys, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result<(), String> { +) -> Result { persist_imported_identity_impl(store, keys, legacy_path, data_dir) } @@ -920,15 +925,6 @@ fn write_migration_marker(marker_path: &std::path::Path) -> Result<(), String> { .map_err(|e| format!("commit migration marker: {e}")) } -/// Which backend [`store_key_preferring_keyring`] wrote to. The caller writes -/// the migration marker only after a keyring success — on the file-fallback arm -/// the key is on disk and a marker would wrongly trip the next Unreachable boot -/// into failing closed. -enum PersistBackend { - Keyring, - File, -} - /// Generate a fresh identity, persist it through the store, return it. /// /// On a keyring-backed persist no file is written, so a later @@ -940,9 +936,10 @@ fn generate_and_persist( store: &impl IdentityKeyStore, legacy_path: &std::path::Path, data_dir: &std::path::Path, -) -> Result { +) -> Result<(Keys, IdentityStorage), String> { let keys = Keys::generate(); - if let PersistBackend::Keyring = store_key_preferring_keyring(store, &keys, legacy_path)? { + let storage = store_key_preferring_keyring(store, &keys, legacy_path)?; + if storage == IdentityStorage::SystemKeyring { let marker_path = migration_marker_path(data_dir); if let Err(e) = write_migration_marker(&marker_path) { eprintln!( @@ -956,7 +953,7 @@ fn generate_and_persist( "buzz-desktop: generated and saved identity pubkey {}", keys.public_key().to_hex() ); - Ok(keys) + Ok((keys, storage)) } /// Persist `keys` through the store, silently falling back to the `0o600` file @@ -968,17 +965,17 @@ fn store_key_preferring_keyring( store: &impl IdentityKeyStore, keys: &Keys, legacy_path: &std::path::Path, -) -> Result { +) -> Result { let nsec = keys .secret_key() .to_bech32() .map_err(|e| format!("encode nsec: {e}"))?; match store.store(IDENTITY_KEY_NAME, &nsec) { - Ok(()) => Ok(PersistBackend::Keyring), + Ok(()) => Ok(IdentityStorage::SystemKeyring), Err(keyring_err) => { eprintln!("buzz-desktop: keyring write failed ({keyring_err}), using file fallback"); save_key_file(legacy_path, keys)?; - Ok(PersistBackend::File) + Ok(IdentityStorage::LocalFile) } } } diff --git a/desktop/src-tauri/src/app_state_tests.rs b/desktop/src-tauri/src/app_state_tests.rs index 485dfaea15..751bcf22e5 100644 --- a/desktop/src-tauri/src/app_state_tests.rs +++ b/desktop/src-tauri/src/app_state_tests.rs @@ -484,7 +484,7 @@ fn fresh_keyring_generate_writes_marker() { let resolved = resolve_identity_with_store(&store, &legacy_path, dir.path()).unwrap(); // The key was stored in the keyring (not the file), and the marker marks it. - assert!(!legacy_path.exists()); + assert!(!legacy_path.exists() && resolved.storage == IdentityStorage::SystemKeyring); assert!(migration_marker_path(dir.path()).exists()); assert_eq!( store @@ -541,7 +541,10 @@ fn fresh_generate_keyring_failure_falls_back_to_file_without_marker() { let from_file = load_key_file(&legacy_path).unwrap(); assert_key_eq(&resolved.keys, &from_file); // No marker: the file is the authoritative store, not the keyring. - assert!(!migration_marker_path(dir.path()).exists()); + assert!( + !migration_marker_path(dir.path()).exists() + && resolved.storage == IdentityStorage::LocalFile + ); } // ── New tests for the three defects fixed in this PR ───────────────────── @@ -786,10 +789,7 @@ fn persist_imported_identity_falls_back_to_file_on_keyring_failure() { let result = persist_imported_identity_impl(&store, &imported_keys, &legacy_path, dir.path()); // The policy core handles the keyring failure — Ok, not Err. - assert!( - result.is_ok(), - "must not propagate keyring failure when file fallback succeeds" - ); + assert_eq!(result.unwrap(), IdentityStorage::LocalFile); // Key is recoverable from the file on next boot. let from_file = load_key_file(&legacy_path).unwrap(); diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index 142e3bac88..33ecf3cfca 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -43,6 +43,7 @@ pub fn get_identity(state: State<'_, AppState>) -> Result Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: state.identity_storage().as_str().to_string(), lost, locked, reset_failed, @@ -334,11 +335,17 @@ pub async fn save_ncryptsec_copy( #[tauri::command] pub async fn import_identity( nsec: String, + password: Option, app_handle: tauri::AppHandle, ) -> Result { tokio::task::spawn_blocking(move || { - let trimmed = nsec.trim(); - let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + // NIP-49 backups require a passphrase and decrypt entirely in Rust. + // Raw nsec/hex input follows the existing parser path unchanged. + let password = password.map(zeroize::Zeroizing::new); + let keys = crate::key_backup::recover_keys_from_input( + &nsec, + password.as_ref().map(|value| value.as_str()), + )?; // Serialize against persist_current_identity: hold this guard for the // full function body so a concurrent stale persist can't overwrite @@ -353,30 +360,14 @@ pub async fn import_identity( std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; let key_path = data_dir.join("identity.key"); - // Persist into the OS keyring first (store → read-back verify → marker → - // delete file). Falls back to the 0o600 file when the keyring is - // unavailable; returns Err only when both backends fail. - let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - - // Update in-memory keys BEFORE clearing recovery flags. The Release - // stores below pair with Acquire loads in get_identity: a reader - // observing false is guaranteed to see the updated keys. - let pubkey = keys.public_key(); - *state.keys.lock().map_err(|e| e.to_string())? = keys; - - // Clear both recovery flags — an import is valid in either lost or - // keyring-locked state and resolves both. In the locked case the - // keyring is unreachable, so persist_imported_identity already fell - // back to identity.key; on the next Unreachable boot the file is - // loaded directly and when the keyring returns the adoption path - // picks it up. - state - .identity_lost - .store(false, std::sync::atomic::Ordering::Release); - state - .keyring_locked - .store(false, std::sync::atomic::Ordering::Release); + let (pubkey, storage) = commit_imported_identity(&state, &data_dir, keys, |keys| { + // Persist into the OS keyring first (store → read-back verify → + // marker → delete file). Falls back to the 0o600 file when the + // keyring is unavailable; returns Err only when both backends fail. + let store = + crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); + crate::app_state::persist_imported_identity(store, keys, &key_path, &data_dir) + })?; let pubkey_hex = pubkey.to_hex(); let display_name = truncated_display_name(&pubkey)?; @@ -386,6 +377,7 @@ pub async fn import_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, @@ -395,6 +387,69 @@ pub async fn import_identity( .map_err(|e| format!("spawn_blocking failed: {e}"))? } +/// Commit an imported identity: durably persist, swap in-memory keys, clear +/// recovery flags, then remove the previous identity's stale app-managed +/// backup. Caller must hold `state.identity_mutation`. +/// +/// Ordering is the contract: +/// +/// 1. `persist` runs FIRST. If it fails (`Err` from both keyring and file +/// fallback), nothing has changed — the previous identity stays live in +/// memory AND its valid canonical `identity.ncryptsec` stays on disk. +/// 2. Only after durable persistence do we swap `state.keys` and clear the +/// recovery flags. +/// 3. Stale-backup cleanup runs LAST and is deliberately best-effort: at that +/// point the import is durably committed, so reporting a cleanup failure +/// as a command `Err` would claim a half-applied import that actually +/// succeeded. The leftover blob is still passphrase-encrypted and is +/// replaced by the next backup creation; we log and move on. +fn commit_imported_identity( + state: &AppState, + data_dir: &std::path::Path, + keys: nostr::Keys, + persist: impl FnOnce(&nostr::Keys) -> Result, +) -> Result<(nostr::PublicKey, crate::app_state::IdentityStorage), String> { + // Capture the previous pubkey up front for post-commit cleanup. + let previous_pubkey = state.keys.lock().map_err(|e| e.to_string())?.public_key(); + + let storage = persist(&keys)?; + + // Update in-memory keys BEFORE clearing recovery flags. The Release + // stores below pair with Acquire loads in get_identity: a reader + // observing false is guaranteed to see the updated keys. + let pubkey = keys.public_key(); + { + let mut active_keys = state.keys.lock().map_err(|e| e.to_string())?; + *active_keys = keys; + state.set_identity_storage(storage); + } + + // Clear both recovery flags — an import is valid in either lost or + // keyring-locked state and resolves both. In the locked case the + // keyring is unreachable, so the persist step already fell back to + // identity.key; on the next Unreachable boot the file is loaded + // directly and when the keyring returns the adoption path picks it up. + state + .identity_lost + .store(false, std::sync::atomic::Ordering::Release); + state + .keyring_locked + .store(false, std::sync::atomic::Ordering::Release); + + // Importing a different identity invalidates the app-managed backup: it + // encrypts the previous key and must not linger mislabeled. Best-effort + // per the ordering contract above. + if let Err(e) = crate::key_backup::cleanup_stale_backup(&previous_pubkey, &pubkey, data_dir) { + eprintln!( + "buzz-desktop: import committed, but stale key backup cleanup failed: {e}; \ + the leftover identity.ncryptsec encrypts the PREVIOUS key and will be \ + replaced by the next backup creation" + ); + } + + Ok((pubkey, storage)) +} + /// Make the current ephemeral identity durable by persisting it to the OS /// keyring (or falling back to identity.key). This is called when the user /// chooses to start a new identity instead of re-importing their previous one @@ -438,11 +493,12 @@ pub async fn persist_current_identity( let key_path = data_dir.join("identity.key"); let store = crate::secret_store::SecretStore::shared(crate::app_state::keyring_service()); - crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; + let storage = + crate::app_state::persist_imported_identity(store, &keys, &key_path, &data_dir)?; - // Keys are already the live identity — only clear identity_lost. - // Release pairs with Acquire in get_identity so readers see - // consistent state. + // Keys are already the live identity. Record where the durable write + // landed before clearing identity_lost. + state.set_identity_storage(storage); state .identity_lost .store(false, std::sync::atomic::Ordering::Release); @@ -454,6 +510,7 @@ pub async fn persist_current_identity( Ok(IdentityInfo { pubkey: pubkey_hex, display_name, + storage: storage.as_str().to_string(), lost: false, locked: false, reset_failed: false, diff --git a/desktop/src-tauri/src/identity_storage.rs b/desktop/src-tauri/src/identity_storage.rs new file mode 100644 index 0000000000..b39c1a0331 --- /dev/null +++ b/desktop/src-tauri/src/identity_storage.rs @@ -0,0 +1,62 @@ +use nostr::Keys; + +use crate::app_state::AppState; + +/// Durable location of the active human identity. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum IdentityStorage { + Ephemeral = 0, + SystemKeyring = 1, + LocalFile = 2, + Environment = 3, +} + +impl IdentityStorage { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Ephemeral => "ephemeral", + Self::SystemKeyring => "system-keyring", + Self::LocalFile => "local-file", + Self::Environment => "environment", + } + } + + fn from_u8(value: u8) -> Self { + match value { + 1 => Self::SystemKeyring, + 2 => Self::LocalFile, + 3 => Self::Environment, + _ => Self::Ephemeral, + } + } +} + +impl AppState { + pub(crate) fn identity_storage(&self) -> IdentityStorage { + IdentityStorage::from_u8( + self.identity_storage + .load(std::sync::atomic::Ordering::Acquire), + ) + } + + pub(crate) fn set_identity_storage(&self, storage: IdentityStorage) { + self.identity_storage + .store(storage as u8, std::sync::atomic::Ordering::Release); + } +} + +/// Recovery state produced by identity resolution. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecoveryState { + None, + Lost, + KeyringLocked, +} + +/// Identity and persistence metadata produced by startup resolution. +pub(crate) struct ResolvedIdentity { + pub(crate) keys: Keys, + pub(crate) recovery: RecoveryState, + pub(crate) storage: IdentityStorage, +} diff --git a/desktop/src-tauri/src/key_backup.rs b/desktop/src-tauri/src/key_backup.rs index 6396911aef..f97bf95a67 100644 --- a/desktop/src-tauri/src/key_backup.rs +++ b/desktop/src-tauri/src/key_backup.rs @@ -13,6 +13,10 @@ use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; use nostr::{FromBech32, Keys, ToBech32}; +/// Bech32 prefix of NIP-49 encrypted secret keys. Import routing is +/// case-insensitive because bech32 permits all-uppercase encodings. +pub const NCRYPTSEC_HRP: &str = "ncryptsec1"; + /// scrypt cost for new backups (2^18 — Gossip's desktop default, ~256 MiB). /// The blob self-describes its cost, so this can be raised later without /// breaking existing backups. @@ -108,6 +112,27 @@ pub fn decrypt_ncryptsec(input: &str, password: &str) -> Result { Ok(Keys::new(secret_key)) } +/// Recover identity keys from either an encrypted NIP-49 backup or the raw +/// nsec/hex formats accepted before encrypted imports were added. +pub fn recover_keys_from_input(input: &str, password: Option<&str>) -> Result { + let trimmed = input.trim(); + let is_ncryptsec = trimmed + .get(..NCRYPTSEC_HRP.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(NCRYPTSEC_HRP)); + + if is_ncryptsec { + let password = password.ok_or_else(|| "key backup requires a password".to_string())?; + decrypt_ncryptsec(trimmed, password) + } else { + Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}")) + } +} + +/// Path of the canonical app-managed backup file. +pub fn backup_file_path(data_dir: &std::path::Path) -> std::path::PathBuf { + data_dir.join(BACKUP_FILE_NAME) +} + /// Atomically write `ncryptsec` to `path` with owner-only permissions, then /// reread and byte-compare. Same crash-safety pattern as /// `app_state::save_key_file`. @@ -140,6 +165,28 @@ pub fn write_backup_file(path: &std::path::Path, ncryptsec: &str) -> Result<(), Ok(()) } +/// Delete the app-managed backup if present. Missing files are already clean. +pub fn delete_backup_file(data_dir: &std::path::Path) -> Result<(), String> { + let path = backup_file_path(data_dir); + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(format!("delete stale backup file: {e}")), + } +} + +/// Remove the app-managed backup only when an import changes identities. +pub fn cleanup_stale_backup( + previous: &nostr::PublicKey, + new: &nostr::PublicKey, + data_dir: &std::path::Path, +) -> Result<(), String> { + if previous != new { + delete_backup_file(data_dir)?; + } + Ok(()) +} + /// Generate a passphrase of `word_count` EFF short-wordlist words joined by /// `separator`, using OS entropy. /// diff --git a/desktop/src-tauri/src/key_backup_tests.rs b/desktop/src-tauri/src/key_backup_tests.rs index e5892ad99e..b9713201e1 100644 --- a/desktop/src-tauri/src/key_backup_tests.rs +++ b/desktop/src-tauri/src/key_backup_tests.rs @@ -79,12 +79,59 @@ fn verify_backup_blob_catches_pubkey_mismatch() { assert!(err.contains("does not match identity"), "{err}"); } +// ── Import key recovery ─────────────────────────────────────────────────────── + +#[test] +fn recover_keys_ncryptsec_happy_path() { + let keys = recover_keys_from_input(&format!(" {SPEC_NCRYPTSEC}\n"), Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); +} + +#[test] +fn recover_keys_ncryptsec_requires_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, None).unwrap_err(); + assert_eq!(err, "key backup requires a password"); +} + +#[test] +fn recover_keys_ncryptsec_wrong_password() { + let err = recover_keys_from_input(SPEC_NCRYPTSEC, Some("wrong")).unwrap_err(); + assert_eq!(err, "wrong backup password or damaged key backup"); +} + +#[test] +fn recover_keys_uppercase_ncryptsec_classifies_as_encrypted() { + let upper = SPEC_NCRYPTSEC.to_ascii_uppercase(); + assert_eq!( + recover_keys_from_input(&upper, None).unwrap_err(), + "key backup requires a password" + ); + let keys = recover_keys_from_input(&upper, Some("nostr")).unwrap(); + assert_eq!(keys.secret_key().to_secret_hex(), SPEC_SECRET_HEX); + + let mut mixed = SPEC_NCRYPTSEC.to_string(); + mixed.replace_range(0..1, "N"); + let err = recover_keys_from_input(&mixed, Some("nostr")).unwrap_err(); + assert!(err.contains("invalid ncryptsec"), "{err}"); +} + +#[test] +fn recover_keys_raw_nsec_path_unchanged() { + let keys = Keys::generate(); + let nsec = keys.secret_key().to_bech32().unwrap(); + let recovered = recover_keys_from_input(&nsec, Some("ignored")).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + let recovered = recover_keys_from_input(&nsec, None).unwrap(); + assert_eq!(recovered.public_key(), keys.public_key()); + assert!(recover_keys_from_input("garbage", None).is_err()); +} + // ── File lifecycle ──────────────────────────────────────────────────────────── #[test] fn write_backup_file_persists_0600_and_verifies() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); let on_disk = std::fs::read_to_string(&path).unwrap(); @@ -101,7 +148,7 @@ fn write_backup_file_persists_0600_and_verifies() { #[test] fn write_backup_file_overwrites_atomically() { let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(BACKUP_FILE_NAME); + let path = backup_file_path(dir.path()); write_backup_file(&path, "ncryptsec1old").unwrap(); write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), SPEC_NCRYPTSEC); @@ -113,6 +160,34 @@ fn write_backup_file_overwrites_atomically() { assert_eq!(entries, vec![std::ffi::OsString::from(BACKUP_FILE_NAME)]); } +#[test] +fn delete_backup_file_is_idempotent() { + let dir = tempfile::tempdir().unwrap(); + delete_backup_file(dir.path()).unwrap(); + let path = backup_file_path(dir.path()); + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + delete_backup_file(dir.path()).unwrap(); + assert!(!path.exists()); +} + +#[test] +fn cleanup_stale_backup_removes_only_on_identity_change() { + let dir = tempfile::tempdir().unwrap(); + let path = backup_file_path(dir.path()); + let a = Keys::generate().public_key(); + let b = Keys::generate().public_key(); + + write_backup_file(&path, SPEC_NCRYPTSEC).unwrap(); + cleanup_stale_backup(&a, &a, dir.path()).unwrap(); + assert!(path.exists(), "same identity must keep the backup"); + + cleanup_stale_backup(&a, &b, dir.path()).unwrap(); + assert!( + !path.exists(), + "identity change must remove the stale backup" + ); +} + #[test] fn generated_passphrase_respects_word_count_and_separator() { let words: std::collections::HashSet<&str> = diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7dcc5994ae..ee2a98f5c1 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ mod egress_guard; mod event_sync; mod events; mod huddle; +mod identity_storage; mod key_backup; mod linux_media; mod managed_agents; diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index 1d9747bc20..3f04d3d7a1 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -6,6 +6,8 @@ use serde::{Deserialize, Deserializer, Serialize}; pub struct IdentityInfo { pub pubkey: String, pub display_name: String, + /// Durable location of the active identity key. + pub storage: String, /// True when the app booted with an ephemeral key because the OS keyring /// was empty despite a prior successful migration (key was externally /// deleted). The frontend routes to the nsec re-import step when true. diff --git a/desktop/src-tauri/src/reset.rs b/desktop/src-tauri/src/reset.rs index d2e35e6839..18ddd80eb8 100644 --- a/desktop/src-tauri/src/reset.rs +++ b/desktop/src-tauri/src/reset.rs @@ -463,6 +463,26 @@ mod tests { assert_eq!(kc.delete_calls.get(), 1, "keychain deleted once"); } + // ── NIP-49: the boot wipe destroys the app-managed key backup ───────────── + + #[test] + fn test_wipe_removes_app_managed_key_backup() { + let tmp = TempDir::new().unwrap(); + let app_data = make_app_data(&tmp); + let backup = crate::key_backup::backup_file_path(&app_data); + std::fs::write(&backup, b"encrypted-backup-bytes").unwrap(); + + write_sentinel(&app_data).unwrap(); + let kc = FakeKeychain::ok(); + let outcome = run_boot_reset_with_keychain(make_ctx(&app_data, &kc, false)); + + assert!(outcome.completed); + assert!( + !backup.exists(), + "sign-out wipe must destroy the app-managed key backup" + ); + } + // ── Test 3: keychain failure keeps sentinel ──────────────────────────────── #[test] diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs new file mode 100644 index 0000000000..b570153185 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.test.mjs @@ -0,0 +1,118 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + MIN_PASSPHRASE_LEN, + downloadDisabled, + isEncrypting, + passphraseIssue, + pendingEncryptPassphrase, + effectivePassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, +} from "./encryptedBackup.ts"; +const reduce = (events, from = initialEncryptedBackupState) => + events.reduce(encryptedBackupReducer, from); +test("password validation mirrors Rust character counting", () => { + assert.equal(passphraseIssue(""), null); + assert.match(passphraseIssue("short"), new RegExp(`${MIN_PASSPHRASE_LEN}`)); + const emoji = "😀".repeat(MIN_PASSPHRASE_LEN); + assert.equal(passphraseIssue(emoji), null); + assert.equal( + effectivePassphrase(reduce([{ type: "set-passphrase", value: emoji }])), + emoji, + ); +}); +test("valid password requests encryption without copying it into events", () => { + const ready = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + ]); + assert.equal(pendingEncryptPassphrase(ready), "one-two-three-four"); + const started = reduce([{ type: "encrypt-started", requestId: 1 }], ready); + assert.equal(isEncrypting(started), true); + assert.equal(started.requestId, 1); + assert.equal(Object.hasOwn(started, "encryptingPassphrase"), false); +}); +test("background encryption remains silent until download is clicked", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.passphrase, "one-two-three-four"); + assert.equal(state.encrypted, "ncryptsec1abc"); + assert.equal(state.ncryptsec, null); + assert.equal(state.savedPassword, false); + assert.equal(state.requestId, null); +}); +test("stale async completions cannot replace current request", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "set-passphrase", value: "five-six-seven-eight" }, + { type: "encrypt-started", requestId: 2 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1stale" }, + ]); + assert.equal(state.requestId, 2); + assert.equal(state.encrypted, null); + assert.equal(state.passphrase, "five-six-seven-eight"); +}); +test("failure clears submitted password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-failed", requestId: 1, message: "keychain unavailable" }, + ]); + assert.equal(state.passphrase, ""); + assert.equal(state.createError, "keychain unavailable"); + assert.equal(state.downloadPending, false); + assert.equal(downloadDisabled(state), true); +}); +test("queued download commits and clears password", () => { + const state = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "download-clicked" }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + ]); + assert.equal(state.ncryptsec, "ncryptsec1abc"); + assert.equal(state.passphrase, ""); + assert.equal(state.savedPassword, true); +}); +test("Back preserves blob for immediate re-download without password", () => { + const made = reduce([ + { type: "set-passphrase", value: "one-two-three-four" }, + { type: "encrypt-started", requestId: 1 }, + { type: "encrypt-succeeded", requestId: 1, ncryptsec: "ncryptsec1abc" }, + { type: "download-clicked" }, + { type: "back-to-password" }, + ]); + assert.equal(made.ncryptsec, "ncryptsec1abc"); + assert.equal(made.passphrase, ""); + assert.equal(downloadDisabled(made), false); +}); +test("starting over discards blob and invalidates late requests", () => { + const made = { + ...initialEncryptedBackupState, + ncryptsec: "ncryptsec1abc", + encrypted: "ncryptsec1abc", + savedPassword: true, + nextRequestId: 3, + }; + const fresh = reduce([{ type: "start-new-backup" }], made); + assert.equal(fresh.ncryptsec, null); + assert.equal(fresh.nextRequestId, 4); + assert.equal( + reduce( + [ + { + type: "encrypt-succeeded", + requestId: 2, + ncryptsec: "ncryptsec1stale", + }, + ], + fresh, + ).ncryptsec, + null, + ); +}); diff --git a/desktop/src/features/onboarding/lib/encryptedBackup.ts b/desktop/src/features/onboarding/lib/encryptedBackup.ts new file mode 100644 index 0000000000..2f7d4a0bb0 --- /dev/null +++ b/desktop/src/features/onboarding/lib/encryptedBackup.ts @@ -0,0 +1,135 @@ +/** Pure state model for NIP-49 backup creation. */ +export const MIN_PASSPHRASE_LEN = 12; + +export type EncryptedBackupState = { + passphrase: string; + requestId: number | null; + nextRequestId: number; + encrypted: string | null; + createError: string | null; + downloadPending: boolean; + ncryptsec: string | null; + savedPassword: boolean; +}; + +export const initialEncryptedBackupState: EncryptedBackupState = { + passphrase: "", + requestId: null, + nextRequestId: 1, + encrypted: null, + createError: null, + downloadPending: false, + ncryptsec: null, + savedPassword: false, +}; + +export type EncryptedBackupEvent = + | { type: "set-passphrase"; value: string } + | { type: "encrypt-started"; requestId: number } + | { type: "encrypt-succeeded"; requestId: number; ncryptsec: string } + | { type: "encrypt-failed"; requestId: number; message: string } + | { type: "download-clicked" } + | { type: "back-to-password" } + | { type: "start-new-backup" }; + +export function encryptedBackupReducer( + state: EncryptedBackupState, + event: EncryptedBackupEvent, +): EncryptedBackupState { + switch (event.type) { + case "set-passphrase": + return { + ...state, + passphrase: event.value, + encrypted: null, + createError: null, + }; + case "encrypt-started": + return { + ...state, + requestId: event.requestId, + nextRequestId: Math.max(state.nextRequestId, event.requestId + 1), + createError: null, + }; + case "encrypt-succeeded": + if (event.requestId !== state.requestId) return state; + if (state.downloadPending) { + return { + ...state, + passphrase: "", + requestId: null, + encrypted: event.ncryptsec, + ncryptsec: event.ncryptsec, + downloadPending: false, + savedPassword: true, + }; + } + return { + ...state, + requestId: null, + encrypted: event.ncryptsec, + }; + case "encrypt-failed": + if (event.requestId !== state.requestId) return state; + return { + ...state, + passphrase: "", + requestId: null, + createError: event.message, + downloadPending: false, + }; + case "download-clicked": + if ( + state.ncryptsec || + state.downloadPending || + (!state.encrypted && !effectivePassphrase(state)) + ) + return state; + return state.encrypted + ? { + ...state, + ncryptsec: state.encrypted, + passphrase: "", + savedPassword: true, + } + : { ...state, downloadPending: true }; + case "back-to-password": + return { ...state, createError: null }; + case "start-new-backup": + return { + ...initialEncryptedBackupState, + nextRequestId: state.nextRequestId + 1, + }; + } +} + +export function passphraseIssue(passphrase: string): string | null { + if (passphrase.length === 0) return null; + return [...passphrase].length < MIN_PASSPHRASE_LEN + ? `Use at least ${MIN_PASSPHRASE_LEN} characters.` + : null; +} +export function effectivePassphrase( + state: EncryptedBackupState, +): string | null { + return [...state.passphrase].length < MIN_PASSPHRASE_LEN + ? null + : state.passphrase; +} +export function pendingEncryptPassphrase( + state: EncryptedBackupState, +): string | null { + if (state.savedPassword || state.encrypted || state.requestId !== null) + return null; + return effectivePassphrase(state); +} +export function isEncrypting(state: EncryptedBackupState): boolean { + return state.requestId !== null; +} +export function downloadDisabled(state: EncryptedBackupState): boolean { + if (state.savedPassword && state.ncryptsec) return false; + return ( + state.downloadPending || + (!state.encrypted && effectivePassphrase(state) === null) + ); +} diff --git a/desktop/src/features/onboarding/lib/keyImportInput.test.mjs b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs new file mode 100644 index 0000000000..bc0bb4b4d7 --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.test.mjs @@ -0,0 +1,73 @@ +/** + * Pure-logic tests for key-import input classification (nsec vs NIP-49 + * ncryptsec) and submit gating. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { nsecEncode } from "nostr-tools/nip19"; +import { generateSecretKey } from "nostr-tools/pure"; +import { + classifyKeyImportInput, + isPlausibleNcryptsec, + keyImportSubmitEnabled, + NCRYPTSEC_ENCODED_LENGTH, +} from "./keyImportInput.ts"; + +// NIP-49 spec vector — structurally valid encrypted backup. +const NCRYPTSEC = + "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p"; + +const VALID_NSEC = nsecEncode(generateSecretKey()); + +test("classify_by_hrp_with_whitespace_tolerance", () => { + assert.equal(classifyKeyImportInput(` ${NCRYPTSEC}\n`), "ncryptsec"); + assert.equal(classifyKeyImportInput(VALID_NSEC), "nsec"); + assert.equal(classifyKeyImportInput("npub1whatever"), "unknown"); + assert.equal(classifyKeyImportInput(""), "unknown"); + // nsec must not be shadowed by the longer HRP check. + assert.equal(classifyKeyImportInput("nsec1"), "nsec"); +}); + +test("uppercase_bech32_encoding_classifies_and_gates_like_lowercase", () => { + // Bech32 permits an all-uppercase encoding; it must route to the + // encrypted path (matching Rust) and be submit-plausible. + const upper = NCRYPTSEC.toUpperCase(); + assert.equal(classifyKeyImportInput(upper), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(upper), true); + assert.equal(keyImportSubmitEnabled(upper, ""), false); + assert.equal(keyImportSubmitEnabled(upper, "hunter2hunter2"), true); + // Mixed case: routed encrypted (Rust reports the accurate error) but + // never plausible/submittable — mixed-case bech32 cannot decode. + const mixed = `N${NCRYPTSEC.slice(1)}`; + assert.equal(classifyKeyImportInput(mixed), "ncryptsec"); + assert.equal(isPlausibleNcryptsec(mixed), false); + assert.equal(keyImportSubmitEnabled(mixed, "hunter2hunter2"), false); +}); + +test("plausible_ncryptsec_requires_complete_checksummed_nip49_payload", () => { + assert.equal(NCRYPTSEC.length, NCRYPTSEC_ENCODED_LENGTH); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC), true); + assert.equal(isPlausibleNcryptsec(` ${NCRYPTSEC}\n`), true); + assert.equal(isPlausibleNcryptsec(NCRYPTSEC.slice(0, -1)), false); + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC}q`), false); + // Same length and charset, but a changed checksum must not advance the UI. + assert.equal(isPlausibleNcryptsec(`${NCRYPTSEC.slice(0, -1)}q`), false); + // '1' and 'b' / 'i' / 'o' are not in the Bech32 data charset. + assert.equal(isPlausibleNcryptsec("ncryptsec1bio"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1"), false); + assert.equal(isPlausibleNcryptsec("ncryptsec1 with spaces"), false); +}); + +test("submit_gating_nsec_path_unchanged", () => { + assert.equal(keyImportSubmitEnabled(VALID_NSEC, ""), true); + assert.equal(keyImportSubmitEnabled("nsec1garbage", ""), false); + assert.equal(keyImportSubmitEnabled("", ""), false); +}); + +test("submit_gating_ncryptsec_requires_passphrase", () => { + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, ""), false); + assert.equal(keyImportSubmitEnabled(NCRYPTSEC, "hunter2hunter2"), true); + // Structurally implausible blob never submits, passphrase or not. + assert.equal(keyImportSubmitEnabled("ncryptsec1bio", "hunter2"), false); +}); diff --git a/desktop/src/features/onboarding/lib/keyImportInput.ts b/desktop/src/features/onboarding/lib/keyImportInput.ts new file mode 100644 index 0000000000..0f6fc609ed --- /dev/null +++ b/desktop/src/features/onboarding/lib/keyImportInput.ts @@ -0,0 +1,127 @@ +/** + * Pure classification + submit gating for the key-import form, unit-testable + * without a DOM. + * + * `ncryptsec1…` is a NIP-49 encrypted backup: no npub preview is possible + * (the pubkey is inside the encrypted payload) and a passphrase is required. + * Password validation happens in Rust at decrypt time; this module performs + * the password-independent Bech32 and NIP-49 structure checks needed to decide + * when the form can safely switch modes. + */ + +import { nsecToNpub } from "@/shared/lib/nostrUtils"; + +export type KeyImportKind = "nsec" | "ncryptsec" | "unknown"; + +const NCRYPTSEC_HRP = "ncryptsec"; +const NIP49_VERSION = 2; +const NIP49_PAYLOAD_BYTES = 91; +/** Current NIP-49 payloads encode to 162 characters including the checksum. */ +export const NCRYPTSEC_ENCODED_LENGTH = 162; +const BECH32_CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"; +const BECH32_GENERATORS = [ + 0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3, +] as const; + +function bech32Polymod(values: readonly number[]): number { + let checksum = 1; + for (const value of values) { + const high = checksum >>> 25; + checksum = ((checksum & 0x1ffffff) << 5) ^ value; + for (let index = 0; index < BECH32_GENERATORS.length; index += 1) { + if ((high >>> index) & 1) checksum ^= BECH32_GENERATORS[index]; + } + } + return checksum >>> 0; +} + +function expandBech32Hrp(hrp: string): number[] { + return [ + ...Array.from(hrp, (character) => character.charCodeAt(0) >>> 5), + 0, + ...Array.from(hrp, (character) => character.charCodeAt(0) & 31), + ]; +} + +function convertFiveBitWordsToBytes(words: readonly number[]): number[] | null { + let accumulator = 0; + let bitCount = 0; + const bytes: number[] = []; + + for (const word of words) { + accumulator = (accumulator << 5) | word; + bitCount += 5; + while (bitCount >= 8) { + bitCount -= 8; + bytes.push((accumulator >>> bitCount) & 0xff); + } + } + + // Bech32 conversion without padding permits fewer than five zero remainder + // bits. Any larger or non-zero remainder is not a canonical byte encoding. + if (bitCount >= 5 || ((accumulator << (8 - bitCount)) & 0xff) !== 0) { + return null; + } + return bytes; +} + +export function classifyKeyImportInput(input: string): KeyImportKind { + const trimmed = input.trim(); + // Case-insensitive on the HRP to match the Rust classifier: an uppercase + // valid backup routes to the encrypted path (and decodes there); mixed + // case routes there too and fails in Rust with the accurate error. + if (trimmed.slice(0, 10).toLowerCase() === "ncryptsec1") return "ncryptsec"; + if (trimmed.startsWith("nsec1")) return "nsec"; + return "unknown"; +} + +/** + * Password-independent NIP-49 validation used for the automatic UI transition. + * A candidate must have canonical casing and length, a valid Bech32 checksum, + * and the current 91-byte/version-2 NIP-49 payload shape. + */ +export function isPlausibleNcryptsec(input: string): boolean { + const trimmed = input.trim(); + if (trimmed.length !== NCRYPTSEC_ENCODED_LENGTH) return false; + if (trimmed !== trimmed.toLowerCase() && trimmed !== trimmed.toUpperCase()) { + return false; + } + + const normalized = trimmed.toLowerCase(); + const separatorIndex = normalized.lastIndexOf("1"); + if ( + separatorIndex !== NCRYPTSEC_HRP.length || + normalized.slice(0, separatorIndex) !== NCRYPTSEC_HRP + ) { + return false; + } + + const encoded = normalized.slice(separatorIndex + 1); + const words = Array.from(encoded, (character) => + BECH32_CHARSET.indexOf(character), + ); + if (words.some((word) => word < 0) || words.length <= 6) return false; + if (bech32Polymod([...expandBech32Hrp(NCRYPTSEC_HRP), ...words]) !== 1) { + return false; + } + + const payload = convertFiveBitWordsToBytes(words.slice(0, -6)); + return ( + payload?.length === NIP49_PAYLOAD_BYTES && payload[0] === NIP49_VERSION + ); +} + +/** + * Whether the import form's submit should be enabled. + * nsec: must derive an npub. ncryptsec: plausible blob + non-empty passphrase. + */ +export function keyImportSubmitEnabled( + input: string, + passphrase: string, +): boolean { + const kind = classifyKeyImportInput(input); + if (kind === "ncryptsec") { + return isPlausibleNcryptsec(input) && passphrase.length > 0; + } + return nsecToNpub(input) !== null; +} diff --git a/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx new file mode 100644 index 0000000000..610c104d95 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupPasswordTimeline.tsx @@ -0,0 +1,130 @@ +import { FileKey2, LockKeyhole, LockOpen } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; + +import { cn } from "@/shared/lib/cn"; + +const BACKUP_KEY_DOTS = [ + "key-dot-1", + "key-dot-2", + "key-dot-3", + "key-dot-4", + "key-dot-5", + "key-dot-6", + "key-dot-7", + "key-dot-8", + "key-dot-9", +] as const; + +const TIMELINE_CONNECTOR_DOTS = [ + "connector-dot-1", + "connector-dot-2", + "connector-dot-3", + "connector-dot-4", +] as const; + +const TIMELINE_DOT_INITIAL = { opacity: 0.35, scale: 0.85 }; +const TIMELINE_DOT_PULSE = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const TIMELINE_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const TIMELINE_TOP_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: index * 0.16, + }), +); +const TIMELINE_BOTTOM_DOT_TRANSITIONS = TIMELINE_CONNECTOR_DOTS.map( + (_, index) => ({ + ...TIMELINE_DOT_TRANSITION, + delay: (index + TIMELINE_CONNECTOR_DOTS.length) * 0.16 + 0.24, + }), +); + +/** + * Decorative timeline shared by backup creation and encrypted-backup restore. + * Backup creation reads key → password → lock; restore reads encrypted file → + * password → unlocked account. The password field is layered over the center. + */ +export function BackupPasswordTimeline({ + className, + mode = "backup", +}: { + className?: string; + mode?: "backup" | "restore"; +}) { + const reduceMotion = useReducedMotion() ?? false; + + return ( +
+ {mode === "restore" ? ( +
+ +
+ ) : ( +
+ {BACKUP_KEY_DOTS.map((dot) => ( + + ))} +
+ )} +
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+
+ {TIMELINE_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ {mode === "restore" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/BackupStep.tsx b/desktop/src/features/onboarding/ui/BackupStep.tsx index ed2184baaa..99d9c6324d 100644 --- a/desktop/src/features/onboarding/ui/BackupStep.tsx +++ b/desktop/src/features/onboarding/ui/BackupStep.tsx @@ -1,183 +1,438 @@ -import { AlertTriangle, Info, RefreshCw } from "lucide-react"; +import { Check, Copy, Eye, EyeOff, Info, ShieldCheck } from "lucide-react"; +import { useReducedMotion } from "motion/react"; import * as React from "react"; import { getNsec } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { writeTextToClipboard } from "@/shared/lib/clipboard"; import { Button } from "@/shared/ui/button"; +import { FuzzyLogo } from "@/shared/ui/buzz-logo/FuzzyLogo"; import { Card } from "@/shared/ui/card"; import { Spinner } from "@/shared/ui/spinner"; -import { ONBOARDING_PRIMARY_CTA_CLASS } from "./OnboardingChrome"; +import { + ONBOARDING_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; import { OnboardingFooter } from "./OnboardingFooter"; import { type OnboardingTransitionDirection, OnboardingSlideTransition, } from "./OnboardingSlideTransition"; -import { NsecMaskedDisplay } from "./NsecMaskedDisplay"; +import { ONBOARDING_KEY_TEXT_CLASS } from "./NsecMaskedDisplay"; /** - * Pure helper so the disabled logic can be unit-tested without a DOM. - * - * Disabled while loading (key not fetched yet) or after a failed load (only - * the explicit "Skip for now" ghost advances past an error). + * How long the "Creating your identity key" loader holds the stage before the + * finished state fades in. Purely perceptual — the key already exists; the + * pause sells the creation moment. */ -export function backupNextDisabled({ - isLoading, - loadError, -}: { - isLoading: boolean; - loadError: string | null; -}): boolean { - return isLoading || loadError !== null; +const INTRO_HOLD_MS = 1400; + +/** + * The creation moment should only be sold once per app session. Module-level + * so remounts (e.g. navigating Back and returning to this step) skip the fake + * hold and show the finished state instantly. + */ +let introPlayed = false; + +const REVEAL_ANIMATION_CLASS = + "animate-in fade-in duration-700 motion-reduce:animate-none"; + +const BACKUP_OPTION_CLASS = + "flex min-h-48 w-full flex-col items-start justify-start px-6 py-5 text-left text-foreground"; + +/** Viewing the key never blocks onboarding — Next is always actionable. */ +export function backupNextDisabled(): boolean { + return false; } type BackupStepProps = { direction: OnboardingTransitionDirection; + identityStorage?: IdentityStorage; onBack: () => void; onNext: () => void; + onOpenPasswordBackup: () => void; + onShowOptions: () => void; + optionsExpanded: boolean; + returningFromSecurity: boolean; }; /** - * Onboarding backup step — shows the user their freshly created key so they - * can save it somewhere safe. Only shown on the fresh-key path. + * Onboarding identity-key step — shows the freshly created key, then opens a + * dark backup-options state. Copy fetches the raw key only after an explicit + * click; password backup opens the separate security flow. Neither method + * blocks Next. */ -export function BackupStep({ direction, onBack, onNext }: BackupStepProps) { +export function BackupStep({ + direction, + identityStorage, + onBack, + onNext, + onOpenPasswordBackup, + onShowOptions, + optionsExpanded, + returningFromSecurity, +}: BackupStepProps) { + const reduceMotion = useReducedMotion() ?? false; + const [created, setCreated] = React.useState(introPlayed || reduceMotion); + const [copyState, setCopyState] = React.useState< + "idle" | "copying" | "copied" + >("idle"); + const [copyError, setCopyError] = React.useState(null); const [nsec, setNsec] = React.useState(null); - const [isLoading, setIsLoading] = React.useState(true); - const [loadError, setLoadError] = React.useState(null); + const [isRevealed, setIsRevealed] = React.useState(false); const cancelledRef = React.useRef(false); + const copiedTimerRef = React.useRef(null); - const loadNsec = React.useCallback(async () => { - setIsLoading(true); - setLoadError(null); - try { - const value = await getNsec(); - if (!cancelledRef.current) setNsec(value); - } catch (err) { - if (!cancelledRef.current) - setLoadError( - err instanceof Error - ? err.message - : "Failed to retrieve private key.", - ); - } finally { - if (!cancelledRef.current) setIsLoading(false); + React.useEffect(() => { + if (introPlayed) return; + if (reduceMotion) { + introPlayed = true; + setCreated(true); + return; } - }, []); + const timer = window.setTimeout(() => { + introPlayed = true; + setCreated(true); + }, INTRO_HOLD_MS); + return () => window.clearTimeout(timer); + }, [reduceMotion]); React.useEffect(() => { cancelledRef.current = false; - void loadNsec(); return () => { // Back-during-fetch: cancel any in-flight setState calls and clear the // nsec from memory on unmount (backup step is only on the fresh-key path). cancelledRef.current = true; setNsec(null); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); }; - }, [loadNsec]); + }, []); + + const copyKeyToClipboard = React.useCallback(async () => { + setCopyState("copying"); + setCopyError(null); + try { + const value = nsec ?? (await getNsec()); + await writeTextToClipboard(value); + if (cancelledRef.current) return; + setCopyState("copied"); + if (copiedTimerRef.current !== null) + window.clearTimeout(copiedTimerRef.current); + copiedTimerRef.current = window.setTimeout(() => { + if (!cancelledRef.current) setCopyState("idle"); + }, 2000); + } catch (err) { + if (cancelledRef.current) return; + setCopyState("idle"); + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [nsec]); + + const toggleReveal = React.useCallback(async () => { + if (isRevealed) { + setIsRevealed(false); + return; + } + setCopyError(null); + try { + // The raw key enters the DOM only after this explicit reveal action. + const value = nsec ?? (await getNsec()); + if (cancelledRef.current) return; + setNsec(value); + setIsRevealed(true); + } catch (err) { + if (cancelledRef.current) return; + setCopyError( + err instanceof Error ? err.message : "Failed to retrieve private key.", + ); + } + }, [isRevealed, nsec]); + + // Fixed-length decorative mask (nsec keys are 63 chars) so no key material + // is fetched just to render the blurred row. Bullets are joined with a + // zero-width space: WebKit won't line-break a run of U+2022 without an + // explicit break opportunity, so the masked row would overflow otherwise. + const maskedKey = React.useMemo( + () => Array.from({ length: nsec?.length ?? 63 }, () => "•").join("\u200b"), + [nsec], + ); + const storageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain. Your computer may ask for your password when Buzz needs to read the key." + : identityStorage === "local-file" + ? "Your system keychain wasn’t available, so Buzz keeps your identity key in a private file on this device." + : "Buzz keeps your identity key protected on this device. Make a separate backup in case you lose access."; + const storageTitle = + identityStorage === "system-keyring" + ? "Protected by your system keychain" + : identityStorage === "local-file" + ? "Stored in private device storage" + : "Protected in private device storage"; + const introStorageDescription = + identityStorage === "system-keyring" + ? "Buzz keeps your identity key in your system keychain." + : identityStorage === "local-file" + ? "Buzz keeps your identity key in a private file on this device because the system keychain wasn’t available." + : "Your identity key is protected on this device."; + + if (optionsExpanded) { + return ( + +
+

+ Backup options +

+

+ Your identity key works like a password for your Buzz account. Keep + a copy somewhere safe. You can create a backup file and lock it with + a password you can remember. +

+
+ +
+
+
+ {storageTitle} + + {storageDescription} + +
+ +
+ + Saved in your password manager + + + Copy your identity key, then save it in a password manager like + 1Password. + + +
+ +
+ + Locked in a backup file + + + Create a backup file and choose a password you can remember. + You’ll need both to restore your account. + + +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can continue + and find it later in Settings > Profile > Identity. +

+ ) : null} +
+
+ ); + } return (
-

- Your unique identity key has been created + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {created + ? "Your unique identity key has been created" + : "Creating your identity key"}

-

- This key is stored in your system keychain, but save it some place - safe in case you ever need to restore your account. -

-
- -
- {isLoading ? ( -
- - Loading your private key… -
- ) : loadError ? ( -
-
- - - Could not retrieve your private key: {loadError}. You can - continue and find it later in Settings > Profile > - Identity. - -
- -
- ) : nsec ? ( - -
- -
-
- ) : ( -

- No key available to back up. -

- )} - - {nsec ? ( -

- - - Never share your private key. Anyone with this key can impersonate - you and access everything in your account. - + review backup options + {" "} + for ways to restore your account.

) : null}
- - + +
+ ) : ( +
+
+ +
+
+

+ {isRevealed && nsec ? nsec : maskedKey} +

+
+ +
+
+ + {copyError ? ( +

+ Could not retrieve your private key: {copyError}. You can + continue and find it later in Settings > Profile > + Identity. +

+ ) : null} - {loadError ? ( +

+ + + Never share your private key. Anyone with this key can + impersonate you and access everything in your account. + +

+
+
+ )} + + {created ? ( + - ) : null} - - + + + ) : null} ); } diff --git a/desktop/src/features/onboarding/ui/BackupTestFlow.tsx b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx new file mode 100644 index 0000000000..9370d5c061 --- /dev/null +++ b/desktop/src/features/onboarding/ui/BackupTestFlow.tsx @@ -0,0 +1,745 @@ +import { Check, CircleHelp, Eye, EyeOff, FileKey2, FileUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + getNsec, + verifyNcryptsecBackup, + type BackupVerification, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Card } from "@/shared/ui/card"; +import { Input } from "@/shared/ui/input"; +import { PubKey } from "@/shared/ui/PubKey"; +import { Spinner } from "@/shared/ui/spinner"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +type BackupTestStage = "drop" | "password" | "success"; + +/** + * Durable progress through the test flow. Owned by the host so navigating + * away (e.g. onboarding Back) and returning doesn't force the user to + * re-drop the file. The password attempt is deliberately NOT part of this + * state — it lives only in short-lived component state and is cleared the + * moment it's submitted or the component unmounts. + */ +export type BackupTestProgress = { + stage: BackupTestStage; + /** Name of the accepted file once the drop check passed. */ + fileName: string | null; + /** Contents of the accepted file, pending or past verification. */ + ncryptsec: string | null; + /** The Rust-verified public identity once decryption succeeded. */ + result: BackupVerification | null; +}; + +export const initialBackupTestProgress: BackupTestProgress = { + stage: "drop", + fileName: null, + ncryptsec: null, + result: null, +}; + +type BackupTestFlowProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When supplied, only this exact just-created file is accepted — the + * onboarding ceremony proves the user saved *that* backup. Without it the + * flow is a general-purpose tester for any key backup file. + */ + expectedNcryptsec?: string; + /** Re-open the native save dialog for another copy of the backup file. */ + onSaveCopy?: () => void; + isSaving?: boolean; + saveError?: string | null; + /** Optional onboarding footer target for the verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Host-owned progress so it survives this component unmounting. */ + progress: BackupTestProgress; + onProgressChange: React.Dispatch>; + /** Fired once when the user completes the test successfully. */ + onVerified?: () => void; +}; + +const BURST_EMOJIS = ["🎉", "✨", "🐝", "🍯", "🔑", "💛"] as const; +const BURST_PARTICLE_COUNT = 18; +const VERIFICATION_CONNECTOR_DOTS = [ + "verification-dot-1", + "verification-dot-2", + "verification-dot-3", + "verification-dot-4", +] as const; +const VERIFICATION_DOT_ANIMATION = { + opacity: [0.35, 1, 0.35], + scale: [0.85, 1.25, 0.85], +}; +const VERIFICATION_DOT_TRANSITION = { + duration: 0.7, + ease: "easeInOut" as const, + repeat: Number.POSITIVE_INFINITY, + repeatDelay: 1.2, +}; +const PRIVATE_KEY_MASK = Array.from({ length: 63 }, () => "•").join("\u200b"); + +type BurstParticle = { + id: number; + x: number; + y: number; + emoji: string; + delay: number; + scale: number; + rotate: number; +}; + +/** + * One-shot radial emoji burst behind the success badge. Purely decorative — + * skipped entirely under reduced motion. + */ +function SuccessBurst() { + const particles = React.useMemo( + () => + Array.from({ length: BURST_PARTICLE_COUNT }, (_, i) => { + const angle = + (i / BURST_PARTICLE_COUNT) * Math.PI * 2 + Math.random() * 0.5; + const distance = 70 + Math.random() * 80; + return { + id: i, + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + emoji: BURST_EMOJIS[i % BURST_EMOJIS.length], + delay: Math.random() * 0.18, + scale: 0.8 + Math.random() * 0.7, + rotate: -120 + Math.random() * 240, + }; + }), + [], + ); + + return ( +
+ {particles.map((particle) => ( + + {particle.emoji} + + ))} +
+ ); +} + +function VerificationConnector({ + delayOffset, + reduceMotion, +}: { + delayOffset: number; + reduceMotion: boolean; +}) { + return ( +
+ {VERIFICATION_CONNECTOR_DOTS.map((dot, index) => ( + + ))} +
+ ); +} + +/** + * "Test your backup" flow: the user drops a backup file onto a large + * dropzone, then enters its password. Verification is a real NIP-49 decrypt + * in Rust — the submitted password is cleared immediately after the result + * and only the derived public identity ever comes back. + */ +export function BackupTestFlow({ + variant = "spotlight", + expectedNcryptsec, + onSaveCopy, + isSaving = false, + saveError, + verifyButtonPortal, + progress, + onProgressChange, + onVerified, +}: BackupTestFlowProps) { + const reduceMotion = useReducedMotion() ?? false; + const { stage, fileName, ncryptsec, result } = progress; + // True while a file drag is anywhere over the window — the drop overlay + // takes over the host surface only for the duration of the drag. + const [isWindowDragging, setIsWindowDragging] = React.useState(false); + const dragDepthRef = React.useRef(0); + + React.useEffect(() => { + // dragenter/dragleave fire per nested element, so track depth to know + // when the drag has actually left the window. + const handleDragEnter = (event: DragEvent) => { + if (!event.dataTransfer?.types.includes("Files")) return; + dragDepthRef.current += 1; + setIsWindowDragging(true); + }; + const handleDragLeave = () => { + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + if (dragDepthRef.current === 0) setIsWindowDragging(false); + }; + const handleDragEnd = () => { + dragDepthRef.current = 0; + setIsWindowDragging(false); + }; + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("drop", handleDragEnd); + window.addEventListener("dragend", handleDragEnd); + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("drop", handleDragEnd); + window.removeEventListener("dragend", handleDragEnd); + }; + }, []); + + // The password attempt is component-local, never host state: it is cleared + // when verification is submitted and when this component unmounts. + const [attempt, setAttempt] = React.useState(""); + const [error, setError] = React.useState(null); + const [isVerifying, setIsVerifying] = React.useState(false); + const [isRevealed, setIsRevealed] = React.useState(false); + const [successNsec, setSuccessNsec] = React.useState(null); + const [isSuccessNsecRevealed, setIsSuccessNsecRevealed] = + React.useState(false); + const [isLoadingSuccessNsec, setIsLoadingSuccessNsec] = React.useState(false); + const [successNsecError, setSuccessNsecError] = React.useState( + null, + ); + const fileInputRef = React.useRef(null); + const passwordInputRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Opaque correlation id so a stale in-flight verification can't commit + // after "Use a different file" or unmount. + const requestRef = React.useRef(0); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + requestRef.current += 1; + setAttempt(""); + }; + }, []); + + React.useEffect(() => { + if (stage === "password") passwordInputRef.current?.focus(); + }, [stage]); + + const handleFile = React.useCallback( + async (file: File) => { + let text: string; + try { + text = (await file.text()).trim(); + } catch { + if (mountedRef.current) setError("Could not read that file."); + return; + } + if (!mountedRef.current) return; + if (!text.toLowerCase().startsWith("ncryptsec1")) { + setError( + expectedNcryptsec + ? "That doesn't look like your key backup. Choose the file you just downloaded." + : "That doesn't look like a key backup file.", + ); + return; + } + if (expectedNcryptsec && text !== expectedNcryptsec.trim()) { + setError("That's a key backup, but not the one you just downloaded."); + return; + } + setError(null); + setAttempt(""); + onProgressChange({ + stage: "password", + fileName: file.name, + ncryptsec: text, + result: null, + }); + }, + [expectedNcryptsec, onProgressChange], + ); + + const handleVerify = React.useCallback(async () => { + if (!ncryptsec || !attempt || isVerifying) return; + const password = attempt; + const requestId = ++requestRef.current; + setIsVerifying(true); + setError(null); + setIsRevealed(false); + // Clear the attempt the moment it's handed to Rust — success or failure, + // the typed password never lingers in the field. + setAttempt(""); + try { + const verified = await verifyNcryptsecBackup(ncryptsec, password); + if (!mountedRef.current || requestId !== requestRef.current) return; + onProgressChange((prev) => ({ + ...prev, + stage: "success", + result: verified, + })); + onVerified?.(); + } catch (err) { + if (mountedRef.current && requestId === requestRef.current) + setError( + err instanceof Error ? err.message : "Could not verify this backup.", + ); + } finally { + if (mountedRef.current && requestId === requestRef.current) + setIsVerifying(false); + } + }, [attempt, isVerifying, ncryptsec, onProgressChange, onVerified]); + + const toggleSuccessNsec = React.useCallback(async () => { + if (isSuccessNsecRevealed) { + setIsSuccessNsecRevealed(false); + return; + } + if (successNsec) { + setIsSuccessNsecRevealed(true); + return; + } + setIsLoadingSuccessNsec(true); + setSuccessNsecError(null); + try { + const value = await getNsec(); + if (!mountedRef.current) return; + setSuccessNsec(value); + setIsSuccessNsecRevealed(true); + } catch (err) { + if (!mountedRef.current) return; + setSuccessNsecError( + err instanceof Error ? err.message : "Could not retrieve your key.", + ); + } finally { + if (mountedRef.current) setIsLoadingSuccessNsec(false); + } + }, [isSuccessNsecRevealed, successNsec]); + + const isSpotlight = variant === "spotlight"; + + if (stage === "success" && result) { + // The onboarding ceremony pins the exact file, so a success there is by + // construction the current identity — celebrate and move on. The general + // tester reports which identity the backup unlocks. + const isCeremony = Boolean(expectedNcryptsec); + return ( +
+ {reduceMotion ? null : } + + + + {isCeremony ? ( +
+

+ Your backup works! +

+

+ File and password verified. Keep them both somewhere safe — + that's all you need to restore your identity. +

+
+

+ {isSuccessNsecRevealed && successNsec + ? successNsec + : PRIVATE_KEY_MASK} +

+ +
+ {successNsecError ? ( +

+ {successNsecError} +

+ ) : null} +
+ ) : ( + <> +

+ This backup works +

+

+ {result.matchesCurrentIdentity + ? "It restores your current Buzz identity." + : "It restores a different identity than the one signed in here."} +

+
+ +
+ + )} +
+ {isCeremony ? null : ( + + )} +
+ ); + } + + return ( +
+ {stage === "drop" ? ( + + { + const file = event.target.files?.[0]; + // Allow re-selecting the same file after an error. + event.target.value = ""; + if (file) void handleFile(file); + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> + + {isWindowDragging ? ( + /* + * Composer-style takeover: fills the nearest positioned host + * surface (the onboarding card / the settings backup row) and is + * itself the drop target, so anywhere on that surface accepts + * the file. + */ + // biome-ignore lint/a11y/noStaticElementInteractions: pointer-only drop target; the select button is the keyboard-accessible path +
event.preventDefault()} + onDrop={(event) => { + event.preventDefault(); + const file = event.dataTransfer.files?.[0]; + if (file) void handleFile(file); + }} + > + + +
+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} + {onSaveCopy ? ( +
+ +
+ ) : null} + {saveError ? ( +

{saveError}

+ ) : null} +
+ ) : ( + + {(() => { + const fileRow = ( +
+ + + + {fileName} + +
+ ); + const passwordField = ( +
+ setAttempt(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleVerify(); + } + }} + placeholder="Your backup password" + ref={passwordInputRef} + type={isRevealed ? "text" : "password"} + value={attempt} + /> + + {error ? ( +

+ {error} +

+ ) : null} +
+ ); + if (!isSpotlight) { + return ( + <> + {fileRow} +

+ Enter the password to prove you can unlock this backup. +

+ {passwordField} + + ); + } + return ( +
+
+ ); + })()} + {(() => { + const verifyButton = ( + + ); + if (verifyButtonPortal === undefined) { + return ( +
{verifyButton}
+ ); + } + return verifyButtonPortal + ? createPortal(verifyButton, verifyButtonPortal) + : null; + })()} +
+ )} +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx new file mode 100644 index 0000000000..3d69150049 --- /dev/null +++ b/desktop/src/features/onboarding/ui/DownloadKeyStep.tsx @@ -0,0 +1,139 @@ +import { motion, useReducedMotion } from "motion/react"; +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; +import { OnboardingFooter } from "./OnboardingFooter"; +import { + type OnboardingTransitionDirection, + OnboardingSlideTransition, +} from "./OnboardingSlideTransition"; +import { + type EncryptedBackupSession, + EncryptedBackupCreator, +} from "./EncryptedBackupCreator"; + +type DownloadKeyStepProps = { + direction: OnboardingTransitionDirection; + /** Backup state owned by the parent flow across the creation and test views. */ + session: EncryptedBackupSession; + onBack: () => void; +}; + +/** + * Password-backup security subview within the identity-key onboarding step. + * The raw key never enters this component: Rust builds the NIP-49 payload + * locally and the native save dialog produces the user-owned file. + */ +export function DownloadKeyStep({ + direction, + session, + onBack, +}: DownloadKeyStepProps) { + const reduceMotion = useReducedMotion() ?? false; + // Once the encrypted payload is saved, the creator advances to its guided + // backup test while this surface keeps its own navigation. + const hasCreated = session.created; + const hasVerifiedBackup = session.verified; + const hasSelectedBackup = session.test.stage === "password"; + const [primaryActionSlot, setPrimaryActionSlot] = + React.useState(null); + + return ( + + + {/* Plain string concat: cn()'s tailwind-merge misreads the custom + text-title size token as conflicting with text-foreground. */} +

+ {hasVerifiedBackup + ? "Your backup is verified" + : hasSelectedBackup + ? "That’s your backup file" + : hasCreated + ? "Optionally, test your backup" + : "Backup your key with a password"} +

+

+ {hasVerifiedBackup + ? "Your file and password can restore your identity." + : hasSelectedBackup + ? "Now enter your password to prove you can unlock it." + : hasCreated + ? "Learn how your backup works. Drop the file you just saved and unlock it with your password." + : "Keep the downloaded file private — you need both it and your password to restore your identity. Save the backup password somewhere safe; Buzz cannot reset it if lost."} +

+
+ +
+
+ +
+ +
+
+
+
+ + +
+ + + + ); +} diff --git a/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx new file mode 100644 index 0000000000..bb76166bd7 --- /dev/null +++ b/desktop/src/features/onboarding/ui/EncryptedBackupCreator.tsx @@ -0,0 +1,885 @@ +import { AlertTriangle, Eye, EyeOff, RefreshCw } from "lucide-react"; +import * as React from "react"; +import { createPortal } from "react-dom"; + +import { + createNcryptsecBackup, + generateBackupPassphrase, + saveNcryptsecCopy, +} from "@/shared/api/tauriIdentity"; +import { cn } from "@/shared/lib/cn"; +import { Button } from "@/shared/ui/button"; +import { Input } from "@/shared/ui/input"; +import { Popover, PopoverAnchor, PopoverContent } from "@/shared/ui/popover"; +import { Spinner } from "@/shared/ui/spinner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/shared/ui/alert-dialog"; +import { + downloadDisabled, + passphraseIssue, + pendingEncryptPassphrase, + encryptedBackupReducer, + initialEncryptedBackupState, + MIN_PASSPHRASE_LEN, + type EncryptedBackupEvent, + type EncryptedBackupState, +} from "../lib/encryptedBackup"; +import { + type BackupTestProgress, + BackupTestFlow, + initialBackupTestProgress, +} from "./BackupTestFlow"; +import { BackupPasswordTimeline } from "./BackupPasswordTimeline"; +import { + ONBOARDING_SECURITY_PRIMARY_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, +} from "./OnboardingChrome"; + +/** Word-count bounds mirroring `key_backup.rs` (Rust clamps regardless). */ +const MIN_GENERATED_WORDS = 3; +const MAX_GENERATED_WORDS = 10; +const DEFAULT_GENERATED_WORDS = 3; + +const SEPARATOR_OPTIONS = [ + { label: "Spaces", value: " " }, + { label: "Hyphens", value: "-" }, + { label: "Periods", value: "." }, + { label: "Commas", value: "," }, +] as const; + +const DEFAULT_SEPARATOR = SEPARATOR_OPTIONS[0].value; + +/** + * Pause after the last keystroke before the background KDF starts, so typing + * past the minimum length doesn't launch an encryption per character. + */ +const ENCRYPT_DEBOUNCE_MS = 400; + +const PENDING_TICKER_MESSAGES = [ + "Downloading once finished", + "Encrypting your password", + "Just a bit longer...", +] as const; + +/** How long each ticker message holds before sliding to the next. */ +const PENDING_TICKER_INTERVAL_MS = 2500; + +/** Matches the `duration-300` slide transition on the ticker column. */ +const PENDING_TICKER_SLIDE_MS = 300; + +/** + * Vertical ticker for the queued-download button label — cycles through the + * pending messages by sliding a stacked column inside a one-line viewport. + * The column ends with a clone of the first message, so the wrap-around + * slides up from the bottom like every other step; once the clone settles, + * the column snaps (transition disabled) back to the real first row. All + * lines render at all times, so the button keeps the width of the longest + * message instead of resizing on each swap. + */ +function PendingDownloadTicker() { + // Index into the rendered column (messages + trailing clone of the first). + const [position, setPosition] = React.useState(0); + const [snap, setSnap] = React.useState(false); + + React.useEffect(() => { + const timer = window.setInterval( + () => setPosition((current) => current + 1), + PENDING_TICKER_INTERVAL_MS, + ); + return () => window.clearInterval(timer); + }, []); + + // The clone is visually identical to the first message: once its slide-in + // finishes, jump back to the real first row without animating. + React.useEffect(() => { + if (position !== PENDING_TICKER_MESSAGES.length) return; + const timer = window.setTimeout(() => { + setSnap(true); + setPosition(0); + }, PENDING_TICKER_SLIDE_MS); + return () => window.clearTimeout(timer); + }, [position]); + + // Re-enable the transition one frame after the snap has painted. + React.useEffect(() => { + if (!snap) return; + const raf = window.requestAnimationFrame(() => setSnap(false)); + return () => window.cancelAnimationFrame(raf); + }, [snap]); + + // The clone row duplicates the first message's text, so it carries its own + // stable key. + const column = [ + ...PENDING_TICKER_MESSAGES.map((message) => ({ key: message, message })), + { key: "wrap-clone", message: PENDING_TICKER_MESSAGES[0] }, + ]; + + return ( + + + {column.map((row) => ( + + {row.message} + + ))} + + + ); +} + +/** + * Everything about an in-progress backup that must survive this component + * unmounting: the reducer state (short-lived passphrase + encrypted blob), whether the + * backup test passed, where the file was saved, the save-once guard, and the + * test-flow progress. Hosts that need the state to outlive the creator (the + * onboarding flow, where Back unmounts the step) call + * `useEncryptedBackupSession` at a longer-lived level and pass it down; + * otherwise the creator owns a private session internally. + */ +export type EncryptedBackupSession = { + state: EncryptedBackupState; + dispatch: React.Dispatch; + /** + * True once the encrypted payload has been committed AND saved to disk. + * Derived so hosts (e.g. DownloadKeyStep) can branch on it without touching + * the blob itself — keeping them outside the ncryptsec confinement scan. + */ + created: boolean; + /** True once the user has passed the backup test. */ + verified: boolean; + setVerified: React.Dispatch>; + savedPath: string | null; + setSavedPath: React.Dispatch>; + /** The committed blob a save was already kicked off for (save-once guard). */ + savedForRef: React.MutableRefObject; + test: BackupTestProgress; + setTest: React.Dispatch>; +}; + +/** Host-side state for `EncryptedBackupCreator` — see `EncryptedBackupSession`. */ +export function useEncryptedBackupSession(): EncryptedBackupSession { + const [state, dispatch] = React.useReducer( + encryptedBackupReducer, + initialEncryptedBackupState, + ); + const [verified, setVerified] = React.useState(false); + const [savedPath, setSavedPath] = React.useState(null); + const savedForRef = React.useRef(null); + const [test, setTest] = React.useState( + initialBackupTestProgress, + ); + return React.useMemo( + () => ({ + state, + dispatch, + created: state.ncryptsec !== null && savedPath !== null, + verified, + setVerified, + savedPath, + setSavedPath, + savedForRef, + test, + setTest, + }), + [state, verified, savedPath, test], + ); +} + +/** + * Return to a secure saved-password placeholder. The encrypted blob survives + * for instant re-download, while no password or test attempt is retained. + */ +export function backupSessionToPasswordEntry( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "back-to-password" }); + session.setVerified(false); + session.setSavedPath(null); + session.setTest(initialBackupTestProgress); +} + +/** Discard all backup-creation and verification progress. */ +export function resetEncryptedBackupSession( + session: EncryptedBackupSession, +): void { + session.dispatch({ type: "start-new-backup" }); + session.setVerified(false); + session.setSavedPath(null); + session.savedForRef.current = null; + session.setTest(initialBackupTestProgress); +} + +type EncryptedBackupCreatorProps = { + /** "spotlight" is the onboarding treatment; "boxed" fits settings cards. */ + variant?: "spotlight" | "boxed"; + /** + * When set, the "Download" button is portaled into this element instead of + * rendering inline. + */ + createButtonPortal?: HTMLElement | null; + /** Optional onboarding footer target for the guided-test verification CTA. */ + verifyButtonPortal?: HTMLElement | null; + /** Extra classes for the "Download" button. */ + createButtonClassName?: string; + /** + * Host-owned session so the backup state survives this component + * unmounting (onboarding Back navigation). Omitted = private session. + */ + session?: EncryptedBackupSession; + /** Fired once the encrypted payload has been created (before saving). */ + onCreated?: () => void; + /** Fired only after the encrypted key file has been saved successfully. */ + onSaved?: (path: string) => void; + /** Whether creation continues into onboarding's guided test ceremony. */ + guidedTest?: boolean; + /** Fired once when the user completes the backup test successfully. */ + onVerified?: () => void; +}; + +/** + * 1Password-style memorable-password generator popover with word-count and + * separator fields, anchored to a refresh icon inset in the password field + * (the anchor assumes a `relative` parent). The first click opens the + * popover and generates; further clicks on the icon re-roll while the + * popover stays open — only click-outside or Esc closes it. There is no + * candidate preview: every generation writes the passphrase straight into + * the parent's password field via `onGenerated`. + */ +function PassphraseGeneratorPopover({ + disabled = false, + onRequestGenerate, + onGenerated, + securityTheme = false, +}: { + disabled?: boolean; + onRequestGenerate?: () => void; + onGenerated: (value: string) => void; + securityTheme?: boolean; +}) { + const [open, setOpen] = React.useState(false); + const [words, setWords] = React.useState(DEFAULT_GENERATED_WORDS); + const [separator, setSeparator] = React.useState(DEFAULT_SEPARATOR); + const [error, setError] = React.useState(null); + const anchorRef = React.useRef(null); + const mountedRef = React.useRef(true); + // Read via a ref so `generate` stays reference-stable even though parents + // pass an inline `onGenerated`. Otherwise each generated password would + // re-render the parent, rebuild `generate`, and re-fire the open/controls + // effect below — an infinite generate loop while the popover is open. + const onGeneratedRef = React.useRef(onGenerated); + + React.useEffect(() => { + onGeneratedRef.current = onGenerated; + }, [onGenerated]); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const generate = React.useCallback(async (wordCount: number, sep: string) => { + setError(null); + try { + const passphrase = await generateBackupPassphrase({ + words: wordCount, + separator: sep, + }); + if (mountedRef.current) onGeneratedRef.current(passphrase); + } catch (err) { + if (!mountedRef.current) return; + setError( + err instanceof Error ? err.message : "Failed to generate a password.", + ); + } + }, []); + + // Fill the password field on every open and whenever a control changes. + React.useEffect(() => { + if (open) void generate(words, separator); + }, [open, words, separator, generate]); + + return ( + + {/* Anchor (not Trigger): Radix triggers toggle on click, but repeat + clicks here must generate a fresh password while the popover stays + open. Only click-outside or Esc closes it. */} + + + + { + // Clicking the anchor icon is "outside" the content — keep the + // popover open so that click re-rolls instead of closing. + if ( + event.target instanceof Node && + anchorRef.current?.contains(event.target) + ) { + event.preventDefault(); + } + }} + onOpenAutoFocus={(event) => event.preventDefault()} + > +
+ +
+ setWords(Number(event.target.value))} + type="range" + value={words} + /> + + {words} + +
+
+ +
+ + +
+ + {error ? ( +

+ + {error} +

+ ) : null} +
+
+ ); +} + +/** + * Password-first encrypted key download flow shared by onboarding and + * Settings. The raw private key never enters this component. Rust creates the + * NIP-49 payload locally, then the native save dialog produces the user-owned + * file. + * + * The flow is a single password input; a refresh icon inset in the field + * opens a 1Password-style generator popover (word count + separator). + * Encryption starts eagerly once the password is valid, so Download usually + * opens the save dialog instantly. Background encryption is silent; clicking + * mid-encryption reveals the queued-download ticker until the KDF finishes. + */ +export function EncryptedBackupCreator({ + variant = "spotlight", + createButtonPortal, + verifyButtonPortal, + createButtonClassName, + session: sessionProp, + onCreated, + onSaved, + guidedTest = true, + onVerified, +}: EncryptedBackupCreatorProps) { + // Hosts without a longer-lived session get a private one (settings card). + const fallbackSession = useEncryptedBackupSession(); + const session = sessionProp ?? fallbackSession; + const { state, dispatch, savedPath, setSavedPath, savedForRef } = session; + const [isRevealed, setIsRevealed] = React.useState(false); + const [saveError, setSaveError] = React.useState(null); + const [isSaving, setIsSaving] = React.useState(false); + const [confirmNewPassword, setConfirmNewPassword] = React.useState(false); + const mountedRef = React.useRef(true); + + React.useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + // A queued download locks the form — mask the password too so it isn't + // left readable on screen while the user waits for the save dialog. + React.useEffect(() => { + if (state.downloadPending) setIsRevealed(false); + }, [state.downloadPending]); + + // Correlate KDF completion by an opaque request id. The password exists only + // in this short-lived effect closure and is cleared from reducer state once + // Rust returns; stale completions cannot commit. + const pendingPassphrase = pendingEncryptPassphrase(state); + const skipDebounce = state.downloadPending; + React.useEffect(() => { + if (!pendingPassphrase) return; + let cancelled = false; + const requestId = state.nextRequestId; + const start = () => { + if (cancelled) return; + dispatch({ type: "encrypt-started", requestId }); + void createNcryptsecBackup(pendingPassphrase) + .then((ncryptsec) => + dispatch({ type: "encrypt-succeeded", requestId, ncryptsec }), + ) + .catch((err: unknown) => + dispatch({ + type: "encrypt-failed", + requestId, + message: + err instanceof Error + ? err.message + : "Failed to encrypt your key.", + }), + ); + }; + const timer = window.setTimeout( + start, + skipDebounce ? 0 : ENCRYPT_DEBOUNCE_MS, + ); + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [dispatch, pendingPassphrase, skipDebounce, state.nextRequestId]); + + // Download commit: fires once per committed blob, whether the commit was + // instant (encryption already done) or resolved a queued download. The flow + // only advances to the test view once the file is actually on disk — a + // canceled save dialog or a save failure rolls the commit back to the + // password form so "Download backup" can be clicked again. + React.useEffect(() => { + const ncryptsec = state.ncryptsec; + if (!ncryptsec || savedForRef.current === ncryptsec) return; + savedForRef.current = ncryptsec; + onCreated?.(); + setIsSaving(true); + setSaveError(null); + const rollBack = () => { + savedForRef.current = null; + dispatch({ type: "back-to-password" }); + }; + void saveNcryptsecCopy(ncryptsec) + .then((path) => { + if (path) { + setSavedPath(path); + onSaved?.(path); + } else { + // User canceled the native save dialog — nothing was downloaded. + rollBack(); + } + }) + .catch((err: unknown) => { + rollBack(); + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + }) + .finally(() => { + if (mountedRef.current) setIsSaving(false); + }); + }, [ + dispatch, + onCreated, + onSaved, + savedForRef, + setSavedPath, + state.ncryptsec, + ]); + + const handleSaveCopy = React.useCallback(async () => { + if (!state.ncryptsec || isSaving) return; + setIsSaving(true); + setSaveError(null); + try { + const path = await saveNcryptsecCopy(state.ncryptsec); + if (mountedRef.current && path) { + setSavedPath(path); + onSaved?.(path); + } + } catch (err) { + if (mountedRef.current) + setSaveError( + err instanceof Error ? err.message : "Failed to save your key.", + ); + } finally { + if (mountedRef.current) setIsSaving(false); + } + }, [isSaving, onSaved, setSavedPath, state.ncryptsec]); + + const { setVerified, test, setTest } = session; + const handleVerified = React.useCallback(() => { + setVerified(true); + onVerified?.(); + }, [onVerified, setVerified]); + + const issue = passphraseIssue(state.passphrase); + const showBackupTimeline = + variant === "spotlight" && + !state.savedPassword && + !state.createError && + !saveError; + + // The test view requires a successful save, not just a committed blob — + // while the native save dialog is open the password form stays put. + if (state.ncryptsec && savedPath && guidedTest) { + return ( +
+ void handleSaveCopy()} + onVerified={handleVerified} + progress={test} + saveError={saveError} + variant={variant} + verifyButtonPortal={verifyButtonPortal} + /> +
+ ); + } + // Without the guided test (settings), a completed save keeps the form + // visible in its saved-password state: masked input, instant re-download, + // and the change-password confirmation guarding any edit. + + return ( +
+
+ {showBackupTimeline ? : null} +
+ { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onPaste={(event) => { + if (state.savedPassword) { + event.preventDefault(); + setConfirmNewPassword(true); + } + }} + onChange={(event) => + dispatch({ type: "set-passphrase", value: event.target.value }) + } + onKeyDown={(event) => { + if (event.key !== "Enter" || event.nativeEvent.isComposing) + return; + event.preventDefault(); + if (downloadDisabled(state) || isSaving) return; + if (state.savedPassword && state.ncryptsec) { + void handleSaveCopy(); + return; + } + dispatch({ type: "download-clicked" }); + }} + placeholder={ + state.savedPassword + ? "" + : `Password (min ${MIN_PASSPHRASE_LEN} characters)` + } + type={isRevealed ? "text" : "password"} + value={state.passphrase} + /> + {state.savedPassword ? ( +
+ •••••••••••••••••••••••••••••••• +
+ ) : null} + {state.savedPassword ? ( + + Backup password saved; hidden for security. + + ) : null} + + setConfirmNewPassword(true) + : undefined + } + onGenerated={(value) => { + dispatch({ type: "set-passphrase", value }); + // A generated password must be visible so the user can save it. + setIsRevealed(true); + }} + securityTheme={variant === "spotlight"} + /> + {issue ? ( +

+ {issue} +

+ ) : null} +
+
+ + {state.savedPassword && state.ncryptsec && savedPath ? ( +
+

+ Backup saved to {savedPath} +

+

+ Your password isn't kept — download another copy anytime, or start + over to choose a new password. +

+
+ ) : null} + + {state.createError ? ( +

+ {state.createError} +

+ ) : null} + + {saveError ? ( +

+ {saveError} +

+ ) : null} + + {(() => { + // A queued download gets an explicit progress treatment. Background + // encryption stays silent until the user asks to download. + const createButton = ( +
+ {state.downloadPending || isSaving ? ( + + ) : null} + +
+ ); + // `undefined` = inline (settings); `null` = slot not mounted yet + // (skip a frame rather than flashing the button inline). + if (createButtonPortal === undefined) + return
{createButton}
; + return createButtonPortal + ? createPortal(createButton, createButtonPortal) + : null; + })()} + + + + Create a new backup password? + + Starting over lets you pick a new password and download a fresh + backup file. Backups you saved earlier will still work — just use + the password you created them with. + + + + + Keep current backup + + { + dispatch({ type: "start-new-backup" }); + setSavedPath(null); + savedForRef.current = null; + setTest(initialBackupTestProgress); + setIsRevealed(false); + }} + > + Start with a new password + + + + +
+ ); +} diff --git a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx index 0376fc9709..a6a02f38c0 100644 --- a/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx +++ b/desktop/src/features/onboarding/ui/KeyringLockedScreen.tsx @@ -22,8 +22,8 @@ export function KeyringLockedScreen() { }, []); const handleImport = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); // Update the identity query cache so useIdentityQuery observers see // locked: false. The bootedLocked latch in hooks.ts will then route // to RelaunchRequiredScreen via bootedLocked && !identityLocked. diff --git a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx index ca87c76636..cee17c68f8 100644 --- a/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/MachineOnboardingFlow.tsx @@ -1,20 +1,33 @@ import * as React from "react"; import type { QueryClient } from "@tanstack/react-query"; +import { ArrowUp } from "lucide-react"; +import { motion, useReducedMotion } from "motion/react"; import { getIdentity, importIdentity, persistCurrentIdentity, } from "@/shared/api/tauriIdentity"; +import type { IdentityStorage } from "@/shared/api/types"; import { Button } from "@/shared/ui/button"; import { StartupWindowDragRegion } from "@/shared/ui/StartupWindowDragRegion"; import { BackupStep } from "./BackupStep"; import { DefaultConfigStep } from "./DefaultConfigStep"; +import { DownloadKeyStep } from "./DownloadKeyStep"; +import { + backupSessionToPasswordEntry, + resetEncryptedBackupSession, + useEncryptedBackupSession, +} from "./EncryptedBackupCreator"; import { IdentityKeyHelpDialog } from "./IdentityKeyHelpDialog"; import { LandingBees } from "./LandingBees"; -import { NostrKeyImportForm } from "./NostrKeyImportForm"; +import { + NostrKeyImportForm, + type NostrKeyImportStage, +} from "./NostrKeyImportForm"; import { ONBOARDING_LANDING_CTA_CLASS, + ONBOARDING_SECONDARY_CTA_CLASS, OnboardingChrome, } from "./OnboardingChrome"; import { OnboardingFooterProvider } from "./OnboardingFooter"; @@ -28,6 +41,8 @@ export type MachineOnboardingPage = | "setup" | "config"; +type BackupSubview = "created" | "options" | "password"; + /** A pending navigation the parent should execute after RouterProvider mounts. */ export type PostOnboardingNavigation = { to: string; @@ -61,10 +76,27 @@ export function MachineOnboardingFlow({ const [error, setError] = React.useState(null); const [isPending, setIsPending] = React.useState(false); const [identityWasImported, setIdentityWasImported] = React.useState(false); + const [keyImportStage, setKeyImportStage] = + React.useState("key-entry"); const [selectedPubkey, setSelectedPubkey] = React.useState( null, ); + const [identityStorage, setIdentityStorage] = React.useState< + IdentityStorage | undefined + >(); const [readyRuntimeIds, setReadyRuntimeIds] = React.useState([]); + const [backupSubview, setBackupSubview] = + React.useState("created"); + const [backupDirection, setBackupDirection] = React.useState< + "forward" | "backward" + >("forward"); + const [returningFromSecurity, setReturningFromSecurity] = + React.useState(false); + // Owned here so switching between the yellow onboarding view and the dark + // security subview keeps the created backup, password, and test progress. + const backupSession = useEncryptedBackupSession(); + const reduceMotion = useReducedMotion() ?? false; + const isSecuritySubview = page === "backup" && backupSubview !== "created"; const handleReadyRuntimeIdsChange = React.useCallback( (runtimeIds: readonly string[]) => { setReadyRuntimeIds(Array.from(new Set(runtimeIds))); @@ -79,6 +111,10 @@ export function MachineOnboardingFlow({ const identity = await getIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -101,6 +137,10 @@ export function MachineOnboardingFlow({ const identity = await persistCurrentIdentity(); queryClient.setQueryData(["identity"], identity); setSelectedPubkey(identity.pubkey); + setIdentityStorage(identity.storage); + setBackupDirection("forward"); + setReturningFromSecurity(false); + setBackupSubview("created"); setPage("backup"); } catch (cause) { setError( @@ -112,8 +152,8 @@ export function MachineOnboardingFlow({ }, [queryClient]); const importExistingIdentity = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); continueWithIdentity(identity.pubkey); queryClient.setQueryData(["identity"], identity); setIdentityWasImported(true); @@ -126,6 +166,8 @@ export function MachineOnboardingFlow({ return (
{page === "identity" ? : null} - {page !== "identity" ? ( + {isSecuritySubview ? ( +
+ +
+ ) : page !== "identity" ? ( @@ -178,9 +237,12 @@ export function MachineOnboardingFlow({ : "Create a new identity key"} -
- - ) : ( - { - setNsecInput(event.target.value); - setImportError(null); - }} - placeholder="nsec1..." - ref={inputRef} - spellCheck={false} - type="password" - value={nsecInput} - /> - )} -
+ + + + ) : ( + { + setNsecInput(event.target.value); + setImportError(null); + }} + placeholder="nsec1..." + ref={inputRef} + spellCheck={false} + type="password" + value={nsecInput} + /> + )} + + ) : null} - {variant === "spotlight" ? null : ( - <> - { - void handleFiles(event.currentTarget.files); - event.currentTarget.value = ""; - }} - ref={fileInputRef} - tabIndex={-1} - type="file" - /> + {/* Hidden file input shared by both variants: the default drop zone and + the spotlight "Choose a backup file" button both open it. Accepts the + .ncryptsec backups our own save flow emits alongside raw .key files. */} + { + void handleFiles(event.currentTarget.files); + event.currentTarget.value = ""; + }} + ref={fileInputRef} + tabIndex={-1} + type="file" + /> - + ) : null} + + {isPasswordStage ? ( +
+ + +
+ { + setPassphrase(event.target.value); + setImportError(null); + }} + placeholder="Backup password" + ref={passphraseInputRef} + spellCheck={false} + type={isRevealed ? "text" : "password"} + value={passphrase} /> - setIsRevealed((current) => !current)} + size="icon" + type="button" + variant="ghost" > - Drop a key here - - - - )} + {isRevealed ? ( +
+
+ ) : null} -
- {previewNpub ? ( - variant === "spotlight" ? ( - // Spotlight uses the backup step's quiet caption language: - // centered, unboxed, with the npub in the shared olive key ink. -
-

-

-

- {previewNpub} -

-
- ) : ( -
- -
-

- This will use this Nostr identity: + {!isPasswordStage || errorMessage ? ( +

+ {!isPasswordStage && previewNpub ? ( + variant === "spotlight" ? ( + // Spotlight uses the backup step's quiet caption language: + // centered, unboxed, with the npub in the shared olive key ink. +
+

+

-

+

{previewNpub}

-
- ) - ) : null} + ) : ( +
+ +
+

+ This will use this Nostr identity: +

+

+ {previewNpub} +

+
+
+ ) + ) : null} - {showInvalidHint && !errorMessage ? ( -

- Waiting for a valid nsec1 key -

- ) : null} + {showInvalidHint && !errorMessage ? ( +

+ {isEncryptedInput + ? "Waiting for a complete ncryptsec backup" + : "Waiting for a valid nsec1 key"} +

+ ) : null} - {errorMessage ? ( -

{errorMessage}

- ) : null} -
+ {errorMessage ? ( +

+ {errorMessage} +

+ ) : null} +
+ ) : null} diff --git a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx index 936313bce0..7a52ae4999 100644 --- a/desktop/src/features/onboarding/ui/OnboardingChrome.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingChrome.tsx @@ -2,8 +2,8 @@ import { BuzzMark } from "@/shared/ui/buzz-logo/BuzzMark"; /** * Positions in the first-launch flow: landing, identity/key, harness setup, - * default config, community choice, community profile, meet the team. Used as - * the default pagination length when a flow doesn't pass an explicit total. + * default config, community choice, community profile, meet the team. Password + * backup is an optional subview of identity/key, not another position. */ export const TOTAL_ONBOARDING_PAGES = 7; @@ -17,6 +17,9 @@ const ONBOARDING_CTA_SHAPE = "h-[2.375rem] rounded-full px-6"; */ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-onboarding-cta-label)]`; +/** Inverted primary action used only on dark backup-security surfaces. */ +export const ONBOARDING_SECURITY_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} bg-white text-black/80 hover:bg-white/90 hover:text-black`; + /** * Primary-CTA styling for the landing screen only: the shared pill with the * chartreuse label (`--buzz-welcome-chartreuse`). The blue label is reserved @@ -24,6 +27,10 @@ export const ONBOARDING_PRIMARY_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- */ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(--buzz-welcome-chartreuse)]`; +/** Shared quiet pill for secondary actions throughout onboarding. */ +export const ONBOARDING_SECONDARY_CTA_CLASS = + "h-9 rounded-full bg-foreground/10 px-6 text-foreground hover:bg-foreground/15 hover:text-foreground"; + /** * Icon-control styling for onboarding surfaces that sit on the textured card: * olive backup ink (`--buzz-onboarding-backup-ink`) with a plain @@ -34,6 +41,10 @@ export const ONBOARDING_LANDING_CTA_CLASS = `${ONBOARDING_CTA_SHAPE} text-[var(- export const ONBOARDING_INK_ICON_CLASS = "text-[color:var(--buzz-onboarding-backup-ink)] hover:bg-transparent hover:text-foreground"; +/** Icon controls on the dark noisy backup surfaces stay visually unboxed. */ +export const ONBOARDING_SECURITY_ICON_CLASS = + "text-muted-foreground hover:bg-transparent hover:text-foreground"; + /** * Shared onboarding chrome shown on every page after the landing screen: a * static Buzz mark pinned to the top-left, and a centered pagination track that diff --git a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx index 01a226e3de..a3653f750f 100644 --- a/desktop/src/features/onboarding/ui/OnboardingFlow.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingFlow.tsx @@ -388,8 +388,8 @@ export function OnboardingFlow({ // key's relay profile reseeds the steps, and a key that already finished // onboarding on this machine skips straight into the app. const importExistingKey = React.useCallback( - async (nsec: string) => { - const identity = await importIdentity(nsec); + async (nsec: string, password?: string) => { + const identity = await importIdentity(nsec, password); relayClient.disconnect(); queryClient.setQueryData(["identity"], identity); queryClient.removeQueries({ queryKey: profileQueryKey }); diff --git a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx index 82d9a0213c..ba5d8b2c87 100644 --- a/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx +++ b/desktop/src/features/onboarding/ui/OnboardingSlideTransition.tsx @@ -14,6 +14,7 @@ export type OnboardingTransitionDirection = "forward" | "backward"; export type OnboardingTransitionEffect = | "fade" | "line-slide" + | "mask-reveal-down" | "mask-reveal-up" | "none"; diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index 431c9b2f51..911ddaf362 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -698,25 +698,28 @@ function SetupStepContent({ /> - - - + {/* Relative row keeps the primary CTA truly centered while Skip + hangs off its right edge without shifting the center. */} +
+ + +
+ + + + + + + + ); +} diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index ba45bc62fb..967e50f5d2 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -35,17 +35,8 @@ import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { emojiDisplayName } from "@/shared/lib/emojiName"; import { rewriteRelayUrl } from "@/shared/lib/mediaUrl"; import { KIND_HUDDLE_STARTED } from "@/shared/constants/kinds"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; +import { DeleteMessageConfirmDialog } from "./DeleteMessageConfirmDialog"; import { DropdownMenu, DropdownMenuContent, @@ -277,35 +268,11 @@ function MoreActionsMenu({ {onDelete ? ( - onDelete(message)} onOpenChange={setIsDeleteDialogOpen} open={isDeleteDialogOpen} - > - - - Delete message? - - This will permanently delete this message and cannot be undone. - - - - - - - - - - - - + /> ) : null} {canReport ? ( diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index df3a734cee..69e4ec67b5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -513,10 +513,9 @@ function MessageComposerImpl({ if (editTargetRef.current && onEditSaveRef.current) { if (isSendingRef.current || isUploadingRef.current) return; const currentPendingImeta = media.pendingImetaRef.current; - const hasMedia = currentPendingImeta.length > 0; - // Empty text + zero attachments is a no-op (don't let edit become an - // effective deletion). - if (!trimmed && !hasMedia) return; + // No empty-edit guard here: clearing an edit to empty (no text, no + // attachments) flows through to onEditSave as empty content, which + // deletes the message instead of publishing it (see handleEditSave). // Build the edit's body + imeta tag set. Coerce `mediaTags ?? []` // because edit semantics use `[]` as the explicit "wipe all diff --git a/desktop/tests/e2e/empty-edit-delete.spec.ts b/desktop/tests/e2e/empty-edit-delete.spec.ts new file mode 100644 index 0000000000..772506571b --- /dev/null +++ b/desktop/tests/e2e/empty-edit-delete.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { installMockBridge } from "../helpers/bridge"; + +// The mock identity's own pre-seeded message in #general (authored by +// DEFAULT_MOCK_IDENTITY.pubkey in e2eBridge.ts). Editing/deleting one's own +// message is exactly Sam's workflow: "delete a message by clearing its edit." +const OWN_MESSAGE_ID = "mock-general-welcome"; +const ORIGINAL_CONTENT = "Welcome to #general"; + +// Open the more-actions menu for a message row and wait for the menu to mount. +async function openMoreActionsMenu( + page: import("@playwright/test").Page, + messageId: string, +) { + const row = page.locator(`[data-message-id="${messageId}"]`); + await row.hover(); + await page.getByTestId(`more-actions-${messageId}`).click(); + await expect(page.locator('[role="menuitem"]').first()).toBeVisible({ + timeout: 5_000, + }); +} + +// Enter edit mode for a message, clear it to empty, and submit — the gesture +// that triggers the empty-edit delete confirmation. +async function submitEmptyEdit( + page: import("@playwright/test").Page, + messageId: string, +) { + await openMoreActionsMenu(page, messageId); + await page.getByTestId(`edit-message-${messageId}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + // Edit mode sets the editor content via Tiptap's async transaction pipeline; + // wait for it to populate before we clear it. + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.press("Backspace"); + await expect(input).toBeEmpty(); + await page.keyboard.press("Enter"); +} + +test.beforeEach(async ({ page }) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); +}); + +test("clearing an edit to empty prompts to delete, then deletes on confirm", async ({ + page, +}) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + // The same "Delete message?" confirmation the Delete menu action shows — an + // empty edit is routed through it, not silently deleted. + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + await expect(dialog).toContainText("Delete message?"); + // Edit mode stays active while the dialog is open — it exits only on confirm. + await expect(page.getByTestId("edit-target")).toBeVisible(); + + // Confirm → the message row is removed and edit mode has exited. + await dialog.getByRole("button", { name: "Delete" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeHidden(); + await expect(row).toBeHidden({ timeout: 5_000 }); +}); + +test("cancelling the empty-edit delete keeps the message", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await submitEmptyEdit(page, OWN_MESSAGE_ID); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible({ timeout: 5_000 }); + + // Cancel → nothing is deleted, the original message survives, and the user is + // left in edit mode (the editing session is preserved, not discarded). + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden({ timeout: 5_000 }); + await expect(page.getByTestId("edit-target")).toBeVisible(); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + ORIGINAL_CONTENT, + ); +}); + +test("a non-empty edit still edits and never deletes", async ({ page }) => { + const row = page.locator(`[data-message-id="${OWN_MESSAGE_ID}"]`); + await expect(row).toBeVisible({ timeout: 10_000 }); + + await openMoreActionsMenu(page, OWN_MESSAGE_ID); + await page.getByTestId(`edit-message-${OWN_MESSAGE_ID}`).click(); + await expect(page.getByTestId("edit-target")).toBeVisible({ timeout: 5_000 }); + const input = page.getByTestId("message-input"); + await expect(input).not.toBeEmpty({ timeout: 5_000 }); + const editedContent = `Edited, not deleted ${Date.now()}`; + + await input.click(); + await page.keyboard.press("ControlOrMeta+A"); + await page.keyboard.type(editedContent); + await page.keyboard.press("Enter"); + + // No delete confirmation, edit mode exits, the row survives with new text. + await expect(page.getByRole("alertdialog")).toHaveCount(0); + await expect(page.getByTestId("edit-target")).toBeHidden({ timeout: 5_000 }); + await expect(row).toBeVisible(); + await expect(page.getByTestId("message-timeline")).toContainText( + editedContent, + ); + await expect(page.getByTestId("message-timeline")).not.toContainText( + ORIGINAL_CONTENT, + ); +}); From d48b0e0eec4d2958f90a3cafa9d974450abe8501 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 31 Jul 2026 07:00:15 -0400 Subject: [PATCH 33/87] feat(desktop): upgrade Pocket TTS model (#3266) ## Context Buzz Desktop currently installs an older Pocket TTS model bundle. The current bundle changes the tokenizer, learned BOS input, recurrent-state contract, and prompt behavior, so updating download URLs alone is not compatible. ## Summary This PR upgrades Buzz Desktop to the current pinned Pocket TTS model. It preserves existing product behavior and the hard 50-token model-input limit while adding the required runtime support, verified acquisition, and crash-safe cache migration. ## Changes - Pins an immutable Pocket TTS revision, artifact names, exact byte sizes, SHA-256 checksums, Mary reference voice, and license. - Loads the bundle-matched SentencePiece tokenizer, learned BOS embedding, and bundle-declared recurrent states. - Uses one pinned Pocket TTS configuration; no precision or model-version selector is added. - Preserves the resident engine's exact `<= 50` token contract without changing Desktop segmentation policy. - Bumps the Pocket cache manifest to v4, verifies size and checksum before adoption, atomically swaps the cache, and recovers the last verified cache after interrupted installs, including an incomplete final directory. - Keeps acquisition, cache migration, worker adoption, and tests within the existing Desktop implementation. - Removes the obsolete model-quality harness, which was coupled to the superseded production prompt and model layout. ## Related issue None. ## Testing Manual listening completed on the exact Desktop build. The updated model improved speech quality and resolved the phrase-start and sample-onset artifacts. Reproducible integrity and model checks are below. ## Screenshots N/A. This changes model installation and speech synthesis, not a visual surface. ## Reviewer-reproducible examples ### Before and after model identity ```sh git show 35305bfc8fd456ca9a17caa1ddbfaabd87d46981:desktop/src-tauri/src/huddle/models.rs \ | grep -E 'sherpa-onnx-pocket-tts|TTS_MODEL_VERSION' git show 211d17c58567448fe7ac95c4fa0ad2b88378849a:desktop/src-tauri/src/huddle/pocket_models.rs \ | grep -E 'MODEL_REPOSITORY|MODEL_REVISION|MODEL_PRECISION|MAX_TOKENS' ``` The target branch identifies the January bundle. The PR branch identifies the immutable April revision, INT8 precision, and 50-token maximum. ### Deterministic runtime validation Use the pinned artifacts listed in `pocket_models.rs` and run the model-dependent Pocket tests with the model directory supplied by the test environment. The checked-in long-sentence fixture must preserve its expected 48 and 44 token split and produce non-silent PCM. ### Manual listening validation John listened to an untrimmed Pocket TTS onset-stress clip generated from the exact user-provided passage, with every sentence synthesized separately and identical 100 ms digital-silence boundaries. The clip used no leading period, onset trimming, gain adjustment, or loudness normalization. The updated model produced better-quality speech and resolved the start-of-sample artifacts. --------- Signed-off-by: John Tennant Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: John Tennant Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- desktop/src-tauri/Cargo.lock | 461 ++++++++- desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/examples/pocket_bench.rs | 116 --- .../src-tauri/examples/pocket_clip_probe.rs | 122 --- .../src-tauri/examples/pocket_onset_probe.rs | 149 --- .../src-tauri/examples/pocket_quality_ab.rs | 519 ---------- desktop/src-tauri/src/huddle/models.rs | 241 +++-- desktop/src-tauri/src/huddle/models_tests.rs | 148 +++ desktop/src-tauri/src/huddle/pocket.rs | 672 ++----------- desktop/src-tauri/src/huddle/pocket_april.rs | 940 ++++++++++++++++++ desktop/src-tauri/src/huddle/pocket_models.rs | 130 +++ desktop/src-tauri/src/huddle/tts.rs | 200 ++-- desktop/src-tauri/src/huddle/tts_tests.rs | 34 +- .../src/huddle/tts_tests/token_split.rs | 24 + 14 files changed, 2047 insertions(+), 1714 deletions(-) delete mode 100644 desktop/src-tauri/examples/pocket_bench.rs delete mode 100644 desktop/src-tauri/examples/pocket_clip_probe.rs delete mode 100644 desktop/src-tauri/examples/pocket_onset_probe.rs delete mode 100644 desktop/src-tauri/examples/pocket_quality_ab.rs create mode 100644 desktop/src-tauri/src/huddle/models_tests.rs create mode 100644 desktop/src-tauri/src/huddle/pocket_april.rs create mode 100644 desktop/src-tauri/src/huddle/pocket_models.rs create mode 100644 desktop/src-tauri/src/huddle/tts_tests/token_split.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 325eb9aa67..aca89d7ed7 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -84,6 +84,7 @@ dependencies = [ "cfg-if 1.0.4", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -707,6 +708,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -731,6 +738,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + [[package]] name = "bip39" version = "2.2.2" @@ -1065,8 +1078,11 @@ dependencies = [ "objc2-app-kit", "objc2-foundation", "opus", + "ort", + "ort-sys", "plist", "png 0.18.1", + "rand 0.10.2", "regex", "reqwest 0.13.4", "rodio", @@ -1074,6 +1090,7 @@ dependencies = [ "rusqlite", "rustls", "security-framework 3.7.0", + "sentencepiece-model", "serde", "serde_json", "serde_yaml", @@ -1093,6 +1110,7 @@ dependencies = [ "tauri-plugin-updater", "tauri-plugin-window-state", "tempfile", + "tokenizers", "tokio", "tokio-tungstenite 0.29.0", "tokio-util", @@ -1562,6 +1580,7 @@ dependencies = [ "itoa", "rustversion", "ryu", + "serde", "static_assertions", ] @@ -1813,6 +1832,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -2137,6 +2166,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "dasp_sample" version = "0.11.0" @@ -2634,6 +2672,12 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "euclid" version = "0.22.14" @@ -2692,6 +2736,17 @@ dependencies = [ "regex", ] +[[package]] +name = "fancy-regex" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298" +dependencies = [ + "bit-set 0.8.0", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fast-srgb8" version = "1.0.0" @@ -4763,6 +4818,39 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "logos" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7251356ef8cb7aec833ddf598c6cb24d17b689d20b993f9d11a3d764e34e6458" +dependencies = [ + "logos-derive", +] + +[[package]] +name = "logos-codegen" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59f80069600c0d66734f5ff52cc42f2dabd6b29d205f333d61fd7832e9e9963f" +dependencies = [ + "beef", + "fnv", + "lazy_static", + "proc-macro2", + "quote", + "regex-syntax", + "syn 2.0.118", +] + +[[package]] +name = "logos-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24fb722b06a9dc12adb0963ed585f19fc61dc5413e6a9be9422ef92c091e731d" +dependencies = [ + "logos-codegen", +] + [[package]] name = "loom" version = "0.7.2" @@ -4872,6 +4960,22 @@ dependencies = [ "libc", ] +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "markup5ever" version = "0.38.0" @@ -4898,6 +5002,16 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "maybe-async" version = "0.2.11" @@ -4997,7 +5111,7 @@ dependencies = [ "mesh-llm-types", "model-artifact", "nostr-sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "rustls", "serde", @@ -5135,7 +5249,7 @@ dependencies = [ "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "rand 0.10.2", "regex-lite", "reqwest 0.12.28", @@ -5224,8 +5338,8 @@ source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05 dependencies = [ "anyhow", "async-trait", - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "rmcp", "schemars 1.2.1", @@ -5261,7 +5375,7 @@ dependencies = [ "anyhow", "hex", "iroh", - "prost", + "prost 0.14.4", "serde_json", "sha2 0.10.9", ] @@ -5376,6 +5490,28 @@ dependencies = [ "tracing", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if 1.0.4", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "mime" version = "0.3.17" @@ -5521,6 +5657,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "more-asserts" version = "0.3.1" @@ -5648,6 +5806,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -6149,7 +6322,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 2.0.2", + "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -6629,7 +6802,7 @@ dependencies = [ "opentelemetry-http", "opentelemetry-proto", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "reqwest 0.12.28", "thiserror 2.0.18", ] @@ -6644,7 +6817,7 @@ dependencies = [ "const-hex", "opentelemetry", "opentelemetry_sdk", - "prost", + "prost 0.14.4", "serde", "serde_json", "tonic", @@ -6710,6 +6883,24 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" + [[package]] name = "os_pipe" version = "1.2.3" @@ -6937,6 +7128,16 @@ dependencies = [ "pest", ] +[[package]] +name = "petgraph" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +dependencies = [ + "fixedbitset 0.5.7", + "indexmap 2.14.0", +] + [[package]] name = "petgraph" version = "0.8.3" @@ -7201,6 +7402,15 @@ dependencies = [ "serde", ] +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "portmapper" version = "0.19.1" @@ -7411,6 +7621,16 @@ dependencies = [ "unarray", ] +[[package]] +name = "prost" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796faa41db3ec313a31f7624d9286acf277b52de526150b7e69f3debf891ee5" +dependencies = [ + "bytes", + "prost-derive 0.13.5", +] + [[package]] name = "prost" version = "0.14.4" @@ -7418,7 +7638,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", - "prost-derive", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" +dependencies = [ + "heck 0.5.0", + "itertools", + "log", + "multimap", + "once_cell", + "petgraph 0.7.1", + "prettyplease", + "prost 0.13.5", + "prost-types 0.13.5", + "regex", + "syn 2.0.118", + "tempfile", ] [[package]] @@ -7431,15 +7671,28 @@ dependencies = [ "itertools", "log", "multimap", - "petgraph", + "petgraph 0.8.3", "prettyplease", - "prost", - "prost-types", + "prost 0.14.4", + "prost-types 0.14.4", "regex", "syn 2.0.118", "tempfile", ] +[[package]] +name = "prost-derive" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a56d757972c98b346a9b766e3f02746cde6dd1cd1d1d563472929fdd74bec4d" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "prost-derive" version = "0.14.4" @@ -7453,13 +7706,35 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "prost-reflect" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5edd582b62f5cde844716e66d92565d7faf7ab1445c8cebce6e00fba83ddb2" +dependencies = [ + "logos", + "miette", + "once_cell", + "prost 0.13.5", + "prost-types 0.13.5", +] + +[[package]] +name = "prost-types" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52c2c1bf36ddb1a1c396b3601a3cec27c2462e45f07c386894ec3ccf5332bd16" +dependencies = [ + "prost 0.13.5", +] + [[package]] name = "prost-types" version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ - "prost", + "prost 0.14.4", ] [[package]] @@ -7526,6 +7801,33 @@ version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" +[[package]] +name = "protox" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f352af331bf637b8ecc720f7c87bf903d2571fa2e14a66e9b2558846864b54a" +dependencies = [ + "bytes", + "miette", + "prost 0.13.5", + "prost-reflect", + "prost-types 0.13.5", + "protox-parse", + "thiserror 1.0.69", +] + +[[package]] +name = "protox-parse" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a462d115462c080ae000c29a47f0b3985737e5d3a995fcdbcaa5c782068dde" +dependencies = [ + "logos", + "miette", + "prost-types 0.13.5", + "thiserror 1.0.69", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -7774,7 +8076,7 @@ dependencies = [ "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7837,7 +8139,7 @@ dependencies = [ "strum", "time", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -7846,6 +8148,43 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "realfft" version = "3.5.0" @@ -8658,6 +8997,18 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" +[[package]] +name = "sentencepiece-model" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b87bf750a8322c3236d7aa63c1f4a6862187d00d2d8b038e1dfe263bfe43ec" +dependencies = [ + "miette", + "prost 0.13.5", + "prost-build 0.13.5", + "protox", +] + [[package]] name = "serde" version = "1.0.228" @@ -9090,8 +9441,8 @@ name = "skippy-protocol" version = "0.74.0" source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" dependencies = [ - "prost", - "prost-build", + "prost 0.14.4", + "prost-build 0.14.4", "protoc-bin-vendored", "serde", ] @@ -9271,6 +9622,18 @@ dependencies = [ "der", ] +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + [[package]] name = "sse-stream" version = "0.2.4" @@ -9652,7 +10015,7 @@ version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fce91f2f0ec87dff7e6bcbbeb267439aa1188703003c6055193c821487400432" dependencies = [ - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -10227,7 +10590,7 @@ dependencies = [ "anyhow", "base64 0.22.1", "bitflags 2.13.0", - "fancy-regex", + "fancy-regex 0.11.0", "filedescriptor", "finl_unicode", "fixedbitset 0.4.2", @@ -10390,6 +10753,39 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str 0.9.1", + "dary_heap", + "derive_builder", + "esaxx-rs", + "fancy-regex 0.14.0", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + [[package]] name = "tokio" version = "1.52.3" @@ -10723,7 +11119,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ "bytes", - "prost", + "prost 0.14.4", "tonic", ] @@ -10904,7 +11300,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" dependencies = [ "memchr", "nom 8.0.0", - "petgraph", + "petgraph 0.8.3", ] [[package]] @@ -11075,6 +11471,15 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -11089,9 +11494,15 @@ checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.2.2", ] +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + [[package]] name = "unicode-width" version = "0.2.2" @@ -11104,6 +11515,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 6f3c03c5a5..248eac107e 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -82,6 +82,9 @@ bytes = "1" futures-util = "0.3" opus = "0.3" neteq = { version = "0.8", default-features = false } +ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] } +ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] } +rand = "0.10" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" @@ -125,6 +128,7 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png", zip = "8" flate2 = "1" sherpa-onnx = "1.12" +sentencepiece-model = "0.1" regex = "1" rusqlite = { version = "0.37", features = ["bundled"] } axum = "0.8" @@ -135,6 +139,7 @@ audioadapter-buffers = "3.0" tempfile = "3" strip-ansi-escapes = "0.2" tracing = "0.1" +tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] } [dev-dependencies] # `test-util` enables tokio's paused-clock (`start_paused`) so the relay diff --git a/desktop/src-tauri/examples/pocket_bench.rs b/desktop/src-tauri/examples/pocket_bench.rs deleted file mode 100644 index b4f5635a95..0000000000 --- a/desktop/src-tauri/examples/pocket_bench.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Cold-vs-warm latency bench for Pocket TTS. -//! -//! This duplicates the small config-building snippet from `huddle::pocket` so it -//! doesn't depend on changing module visibility for a one-off dev tool. -//! Keep in sync with `huddle::pocket::load_text_to_speech`. -//! -//! Run with the model files in a directory (defaults to /tmp/pocket-tts-bench): -//! cargo run --release --example pocket_bench -//! cargo run --release --example pocket_bench /path/to/pocket-tts - -use std::path::PathBuf; -use std::time::Instant; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; -const TEST_TEXT: &str = - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx."; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - println!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let t0 = Instant::now(); - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - let load_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Engine load: {load_ms:.1} ms"); - - let t0 = Instant::now(); - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let samples = wave.samples().to_vec(); - let sr = wave.sample_rate(); - let voice_ms = t0.elapsed().as_secs_f32() * 1000.0; - println!("Voice load: {voice_ms:.1} ms"); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(samples.clone()), - reference_sample_rate: sr, - ..Default::default() - }; - - let t0 = Instant::now(); - let cold = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("cold synth"); - let cold_ms = t0.elapsed().as_secs_f32() * 1000.0; - let cold_audio_ms = (cold.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let cold_rtf_x = cold_audio_ms / cold_ms; - println!( - "Cold synth: {cold_ms:.1} ms → {cold_audio_ms:.1} ms audio → {cold_rtf_x:.2}× realtime" - ); - - let t0 = Instant::now(); - let warm = engine - .generate_with_config(TEST_TEXT, &gen(), None:: bool>) - .expect("warm synth"); - let warm_ms = t0.elapsed().as_secs_f32() * 1000.0; - let warm_audio_ms = (warm.samples().len() as f32 / SAMPLE_RATE as f32) * 1000.0; - let warm_rtf_x = warm_audio_ms / warm_ms; - println!( - "Warm synth: {warm_ms:.1} ms → {warm_audio_ms:.1} ms audio → {warm_rtf_x:.2}× realtime" - ); - - let out_path = "/tmp/pocket_bench_out.wav"; - let ok = sherpa_onnx::write(out_path, warm.samples(), SAMPLE_RATE as i32); - println!( - "Wrote {} ({} samples, ok={ok})", - out_path, - warm.samples().len() - ); - - let delta_ms = cold_ms - warm_ms; - let delta_pct = (delta_ms / warm_ms) * 100.0; - println!(); - println!("Cold/warm delta: {delta_ms:+.1} ms ({delta_pct:+.1}%)"); - println!( - "Decision: warmup {}.", - if delta_ms > 200.0 { - "RECOMMENDED — significant cold-call penalty" - } else if delta_ms > 50.0 { - "OPTIONAL — small cold-call penalty" - } else { - "UNNECESSARY — cold and warm essentially equal" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_clip_probe.rs b/desktop/src-tauri/examples/pocket_clip_probe.rs deleted file mode 100644 index ad8657599f..0000000000 --- a/desktop/src-tauri/examples/pocket_clip_probe.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Clipping probe for any fixed playback gain applied after Pocket TTS synth. -//! -//! Synthesises a spread of sentences (short/long, calm/energetic) and reports -//! the raw peak of each, the post-gain peak, and the fraction of samples that -//! would hit a ±1.0 clamp — i.e. how much a fixed gain would flat-top the -//! waveform ("blown out" distortion). -//! -//! History: the production pipeline briefly shipped a fixed 9.3× gain -//! calibrated on a single bench utterance that peaked at 0.076. This probe -//! showed real output peaks at 0.4–0.97, so that gain clipped 13–34% of all -//! samples (the 2026-06-12 "blown out" report). Production now applies no -//! gain — run this probe before reintroducing one. -//! -//! Run with model files in ~/.buzz/models/pocket-tts (override with arg 1): -//! cargo run --release --example pocket_clip_probe - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -/// Candidate gain under test (the regressed production value). -const GAIN: f32 = 9.3; - -const PROMPTS: &[&str] = &[ - "Hello, this is a test of the new Pocket TTS engine running on sherpa-onnx.", - "Yep, I can hear you.", - "Absolutely! That sounds fantastic, let's do it right now!", - "The quick brown fox jumps over the lazy dog near the riverbank.", - "I found three problems in the code: a race condition, a memory leak, and an off-by-one error in the loop bounds.", - "No.", - "Warning! The build failed because seventeen tests crashed unexpectedly!", - "Sure, I can walk you through the whole pipeline step by step whenever you're ready.", -]; - -fn main() { - let model_dir = std::env::args().nth(1).unwrap_or_else(|| { - dirs::home_dir() - .expect("home dir") - .join(".buzz/models/pocket-tts") - .to_string_lossy() - .into_owned() - }); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - let gen = || GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - - let _ = engine.generate_with_config("warmup.", &gen(), None:: bool>); - - println!( - "{:<46} | {:>8} | {:>9} | {:>9} | {:>10}", - "prompt", "raw peak", "raw RMS", "post-gain", "% clipped" - ); - println!("{}", "-".repeat(95)); - - let mut worst_clip = 0.0f32; - for prompt in PROMPTS { - let out = engine - .generate_with_config(prompt, &gen(), None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let peak = samples.iter().fold(0.0f32, |m, s| m.max(s.abs())); - let rms = (samples.iter().map(|s| s * s).sum::() / samples.len() as f32).sqrt(); - let post = peak * GAIN; - let clipped = samples.iter().filter(|s| s.abs() * GAIN > 1.0).count(); - let clip_pct = 100.0 * clipped as f32 / samples.len() as f32; - worst_clip = worst_clip.max(clip_pct); - - let label: String = prompt.chars().take(44).collect(); - println!("{label:<46} | {peak:>8.4} | {rms:>9.4} | {post:>9.3} | {clip_pct:>9.3}%"); - } - - println!(); - println!( - "Verdict: worst-case clipped fraction {worst_clip:.3}% — {}", - if worst_clip > 0.1 { - "AUDIBLE DISTORTION LIKELY (gain too hot)" - } else if worst_clip > 0.0 { - "marginal — occasional transient clipping" - } else { - "no clipping at this gain" - } - ); -} diff --git a/desktop/src-tauri/examples/pocket_onset_probe.rs b/desktop/src-tauri/examples/pocket_onset_probe.rs deleted file mode 100644 index 05b4d0193c..0000000000 --- a/desktop/src-tauri/examples/pocket_onset_probe.rs +++ /dev/null @@ -1,149 +0,0 @@ -//! Onset-attenuation probe for Pocket TTS. -//! -//! Synthesises a handful of short sentences and dumps per-sentence onset -//! statistics (samples[0], 1ms/5ms/20ms peak + RMS) so we can decide whether -//! the production `apply_fades` 8 ms fade-in is masking real audio. -//! -//! Also writes the raw (un-faded, un-normalised) audio of each sentence to -//! /tmp so they can be inspected in Audacity / aplay without rodio in the -//! loop. -//! -//! Run with model files in /tmp/pocket-tts-bench (override with arg 1): -//! cargo run --release --example pocket_onset_probe -//! cargo run --release --example pocket_onset_probe /path/to/pocket-tts - -use std::path::PathBuf; - -use sherpa_onnx::{ - self, GenerationConfig, OfflineTts, OfflineTtsConfig, OfflineTtsModelConfig, - OfflineTtsPocketModelConfig, Wave, -}; - -const SAMPLE_RATE: u32 = 24_000; - -/// Test prompts chosen to span different onsets: -/// - palatal glide 'Y' (soft onset) -/// - voiceless fricative 'H' (very soft onset) -/// - labio-velar glide 'W' (medium onset) -/// - voiceless stop 'T' (hard onset) -const PROMPTS: &[&str] = &[ - "Yep, I can hear you.", - "Hello there friend.", - "What can I help with?", - "Try this experiment now.", -]; - -fn main() { - let model_dir = std::env::args() - .nth(1) - .unwrap_or_else(|| "/tmp/pocket-tts-bench".to_string()); - eprintln!("Model dir: {model_dir}"); - - let dir = PathBuf::from(&model_dir); - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - - let cfg = OfflineTtsConfig { - model: OfflineTtsModelConfig { - pocket: OfflineTtsPocketModelConfig { - lm_main: Some(p("lm_main.int8.onnx")), - lm_flow: Some(p("lm_flow.int8.onnx")), - encoder: Some(p("encoder.onnx")), - decoder: Some(p("decoder.int8.onnx")), - text_conditioner: Some(p("text_conditioner.onnx")), - vocab_json: Some(p("vocab.json")), - token_scores_json: Some(p("token_scores.json")), - voice_embedding_cache_capacity: 16, - }, - num_threads: 1, - debug: false, - ..Default::default() - }, - ..Default::default() - }; - let engine = OfflineTts::create(&cfg).expect("engine create"); - - let voice_path = dir.join("reference_sample.wav"); - let wave = Wave::read(voice_path.to_str().unwrap()).expect("voice WAV"); - let voice_samples = wave.samples().to_vec(); - let voice_sr = wave.sample_rate(); - - // Warmup so we're not measuring cold-call jitter. - { - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let _ = engine.generate_with_config("warmup.", &cfg, None:: bool>); - } - - println!( - "{:<28} | {:>10} | {:>10} {:>10} | {:>10} {:>10} | {:>10} {:>10}", - "prompt", - "samples[0]", - "peak@1ms", - "rms@1ms", - "peak@5ms", - "rms@5ms", - "peak@20ms", - "rms@20ms" - ); - println!("{}", "-".repeat(120)); - - for prompt in PROMPTS { - // Mirror the production prompt-prep (capitalise + terminal punctuation). - // These prompts already have it, so this is just to match what - // sherpa-onnx sees in production. - let cfg = GenerationConfig { - speed: 1.05, - num_steps: 1, - silence_scale: 1.0, // production setting (huddle::pocket::SYNTH_SILENCE_SCALE) - reference_audio: Some(voice_samples.clone()), - reference_sample_rate: voice_sr, - ..Default::default() - }; - let out = engine - .generate_with_config(prompt, &cfg, None:: bool>) - .expect("synth"); - let samples = out.samples(); - - let n_1ms = (SAMPLE_RATE as f32 * 0.001) as usize; - let n_5ms = (SAMPLE_RATE as f32 * 0.005) as usize; - let n_20ms = (SAMPLE_RATE as f32 * 0.020) as usize; - - let stats = |range: &[f32]| -> (f32, f32) { - if range.is_empty() { - return (0.0, 0.0); - } - let peak = range.iter().fold(0.0_f32, |a, &x| a.max(x.abs())); - let sumsq: f32 = range.iter().map(|x| x * x).sum(); - let rms = (sumsq / range.len() as f32).sqrt(); - (peak, rms) - }; - - let first = samples.first().copied().unwrap_or(0.0); - let (p1, r1) = stats(&samples[..n_1ms.min(samples.len())]); - let (p5, r5) = stats(&samples[..n_5ms.min(samples.len())]); - let (p20, r20) = stats(&samples[..n_20ms.min(samples.len())]); - - println!( - "{:<28} | {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6} | {:>10.6} {:>10.6}", - prompt, first, p1, r1, p5, r5, p20, r20 - ); - - let safe: String = prompt - .chars() - .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) - .collect(); - let out_path = format!("/tmp/pocket_onset_{}.wav", &safe[..safe.len().min(24)]); - let _ = sherpa_onnx::write(&out_path, samples, SAMPLE_RATE as i32); - eprintln!( - " → wrote {out_path} ({} samples = {:.3} s)", - samples.len(), - samples.len() as f32 / SAMPLE_RATE as f32 - ); - } -} diff --git a/desktop/src-tauri/examples/pocket_quality_ab.rs b/desktop/src-tauri/examples/pocket_quality_ab.rs deleted file mode 100644 index 0c31f1c910..0000000000 --- a/desktop/src-tauri/examples/pocket_quality_ab.rs +++ /dev/null @@ -1,519 +0,0 @@ -//! Reproducible blind Pocket TTS quality corpus generator. -//! -//! Renders Buzz's production prompt preparation and post-processing across: -//! INT8/FP32 × per-sentence/grouped generation. The generated filenames are -//! deterministically blinded; keep `key.json` away from listeners until their -//! scoring sheet is complete. -//! -//! Usage: -//! cargo run --release --example pocket_quality_ab -- \ -//! [--idle-minutes N --only ITEM] -//! -//! The optional idle run intentionally creates one engine per condition, warms -//! all four, sleeps once, and then makes each clip the first generation after -//! dormancy. It requires `--only` because only the first synthesis after an -//! uninterrupted idle is a valid post-idle observation. Run each 5/15-minute -//! item as a separate process. - -// Importing the production module also brings in runtime-only helpers that this -// standalone corpus generator deliberately does not call. -#![allow(dead_code)] - -#[path = "../src/huddle/pocket.rs"] -mod production_pocket; -#[path = "../src/huddle/preprocessing.rs"] -mod production_preprocessing; - -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; - -use serde::Serialize; -use sha2::{Digest, Sha256}; -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; - -use production_pocket::{prepare_pocket_prompt, SAMPLE_RATE}; -use production_preprocessing::{preprocess_for_tts, split_sentences}; - -const NUM_STEPS: i32 = 1; -const SILENCE_SCALE: f32 = 1.0; -const INTER_SENTENCE_SILENCE_SAMPLES: usize = SAMPLE_RATE as usize / 10; -const LEAD_IN_SAMPLES: usize = SAMPLE_RATE as usize / 50; -const FADE_OUT_SAMPLES: usize = SAMPLE_RATE as usize * 8 / 1000; -const TARGET_RMS_DBFS: f32 = -23.0; -const BLINDING_SEED: &str = "pocket-quality-2026-07-21-v1"; - -const CORPUS: &[CorpusItem] = &[ - CorpusItem { id: "short_one_word", kind: "short", text: "Yep." }, - CorpusItem { id: "short_four_words", kind: "short", text: "Sounds good to me." }, - CorpusItem { - id: "multi_relay_review", - kind: "multi-sentence", - text: "I looked at the relay code this morning. The lease logic is solid. There's one race in the worker claim path, though. I'll write it up and send you a patch.", - }, - CorpusItem { - id: "multi_community_size", - kind: "multi-sentence", - text: "Great question. The answer is it depends on the community size. For small ones, keep it simple.", - }, - CorpusItem { - id: "mixed_agent_message", - kind: "mixed", - text: "That's 42 open PRs right now — mostly small. I'll triage them after lunch.", - }, -]; - -#[derive(Clone, Copy)] -struct CorpusItem { - id: &'static str, - kind: &'static str, - text: &'static str, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Precision { - Int8, - Fp32, -} - -#[derive(Clone, Copy, Debug, Serialize)] -#[serde(rename_all = "snake_case")] -enum Chunking { - PerSentence, - Grouped, -} - -#[derive(Clone, Copy, Debug)] -struct Condition { - precision: Precision, - chunking: Chunking, -} - -const CONDITIONS: [Condition; 4] = [ - Condition { - precision: Precision::Int8, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Int8, - chunking: Chunking::Grouped, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::PerSentence, - }, - Condition { - precision: Precision::Fp32, - chunking: Chunking::Grouped, - }, -]; - -#[derive(Serialize)] -struct KeyFile { - warning: &'static str, - blinding_seed: &'static str, - target_rms_dbfs: f32, - items: Vec, -} - -#[derive(Serialize)] -struct KeyItem { - id: String, - kind: String, - text: String, - clips: Vec, -} - -#[derive(Serialize)] -struct KeyClip { - file: String, - precision: Precision, - chunking: Chunking, - cold_start: bool, - idle_minutes: Option, - synthesis_ms: u128, - audio_seconds: f32, -} - -struct Voice { - samples: Vec, - sample_rate: i32, -} - -struct Engine { - inner: OfflineTts, - voice: Voice, -} - -fn main() -> Result<(), String> { - let mut args = std::env::args().skip(1); - let int8_dir = required_path(args.next(), "INT8 model directory")?; - let fp32_dir = required_path(args.next(), "FP32 model directory")?; - let output_dir = required_path(args.next(), "output directory")?; - let mut idle_minutes = None; - let mut only_item = None; - while let Some(arg) = args.next() { - match arg.as_str() { - "--idle-minutes" => { - idle_minutes = Some( - args.next() - .ok_or("--idle-minutes requires a value")? - .parse::() - .map_err(|e| format!("invalid idle minutes: {e}"))?, - ); - } - "--only" => only_item = Some(args.next().ok_or("--only requires an item ID")?), - _ => return Err(format!("unknown argument: {arg}")), - } - } - - if idle_minutes.is_some() && only_item.is_none() { - return Err("--idle-minutes requires --only so every clip is first-after-idle".into()); - } - if let Some(ref requested) = only_item { - if !CORPUS.iter().any(|item| item.id == requested) { - return Err(format!("unknown corpus item for --only: {requested}")); - } - } - - validate_model_dir(&int8_dir, Precision::Int8)?; - validate_model_dir(&fp32_dir, Precision::Fp32)?; - fs::create_dir_all(&output_dir).map_err(|e| e.to_string())?; - - let mut engines = Vec::with_capacity(CONDITIONS.len()); - for condition in CONDITIONS { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - // Production warms once before serving a real utterance. Cold cases use - // separate fresh engines below and deliberately skip this call. - synth_chunks(&engine, &["warmup".to_string()])?; - engines.push(engine); - } - - if let Some(minutes) = idle_minutes { - eprintln!("All four warmed engines idle for {minutes} minute(s)…"); - std::thread::sleep(Duration::from_secs(minutes * 60)); - } - - let mut key_items = Vec::new(); - for item in CORPUS { - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let preprocessed = preprocess_for_tts(item.text); - let per_sentence: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - // These corpus texts are deliberately below the upstream ~50-token - // grouping target, so grouped mode is one exact generate() call. - let grouped = vec![per_sentence.join(" ")]; - let item_dir = output_dir.join(item.id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(item.id); - let mut clips = Vec::new(); - - let mut rendered = Vec::new(); - for (condition_index, engine) in engines.iter().enumerate() { - let condition = CONDITIONS[condition_index]; - let chunks = match condition.chunking { - Chunking::PerSentence => &per_sentence, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{}/{file_name}", item.id), - precision: condition.precision, - chunking: condition.chunking, - cold_start: false, - idle_minutes, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: item.id.to_string(), - kind: item.kind.to_string(), - text: item.text.to_string(), - clips, - }); - } - - // Explicit fresh-engine cold-start clips for the two highest-signal texts. - // Idle runs intentionally omit them: they happen after the post-idle clips - // and add no valid idle observation. - for item in if idle_minutes.is_none() { CORPUS } else { &[] } { - if !matches!(item.id, "short_one_word" | "multi_relay_review") { - continue; - } - if only_item - .as_deref() - .is_some_and(|requested| requested != item.id) - { - continue; - } - let cold_id = format!("cold_{}", item.id); - let preprocessed = preprocess_for_tts(item.text); - let sentences: Vec = split_sentences(&preprocessed) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - let grouped = vec![sentences.join(" ")]; - let item_dir = output_dir.join(&cold_id); - fs::create_dir_all(&item_dir).map_err(|e| e.to_string())?; - let clip_order = blinded_order(&cold_id); - let mut clips = Vec::new(); - let mut rendered = Vec::new(); - for (condition_index, condition) in CONDITIONS.iter().copied().enumerate() { - let dir = match condition.precision { - Precision::Int8 => &int8_dir, - Precision::Fp32 => &fp32_dir, - }; - let engine = load_engine(dir, condition.precision)?; - let chunks = match condition.chunking { - Chunking::PerSentence => &sentences, - Chunking::Grouped => &grouped, - }; - let started = Instant::now(); - let audio = synth_chunks(&engine, chunks)?; - rendered.push(( - condition_index, - condition, - audio, - started.elapsed().as_millis(), - )); - } - loudness_match_item(&mut rendered); - for (condition_index, condition, audio, synth_ms) in rendered { - let clip_number = clip_order[condition_index] + 1; - let file_name = format!("clip{clip_number}.wav"); - write_wav(&item_dir.join(&file_name), &audio)?; - clips.push(KeyClip { - file: format!("{cold_id}/{file_name}"), - precision: condition.precision, - chunking: condition.chunking, - cold_start: true, - idle_minutes: None, - synthesis_ms: synth_ms, - audio_seconds: audio.len() as f32 / SAMPLE_RATE as f32, - }); - } - clips.sort_by(|a, b| a.file.cmp(&b.file)); - key_items.push(KeyItem { - id: cold_id, - kind: "cold-start".to_string(), - text: item.text.to_string(), - clips, - }); - } - - let key = KeyFile { - warning: "DO NOT OPEN UNTIL LISTENING SCORES ARE FINAL", - blinding_seed: BLINDING_SEED, - target_rms_dbfs: TARGET_RMS_DBFS, - items: key_items, - }; - fs::write( - output_dir.join("key.json"), - serde_json::to_vec_pretty(&key).map_err(|e| e.to_string())?, - ) - .map_err(|e| e.to_string())?; - write_scoring_sheet(&output_dir, &key)?; - println!("Wrote blind corpus to {}", output_dir.display()); - println!("Give listeners the WAV folders and SCORING.md; withhold key.json."); - Ok(()) -} - -fn required_path(value: Option, label: &str) -> Result { - value - .map(PathBuf::from) - .ok_or_else(|| format!("missing {label}")) -} - -fn model_file(precision: Precision, base: &str) -> String { - match precision { - Precision::Int8 => format!("{base}.int8.onnx"), - Precision::Fp32 => format!("{base}.onnx"), - } -} - -fn validate_model_dir(dir: &Path, precision: Precision) -> Result<(), String> { - for file in [ - model_file(precision, "lm_main"), - model_file(precision, "lm_flow"), - "encoder.onnx".into(), - model_file(precision, "decoder"), - "text_conditioner.onnx".into(), - "vocab.json".into(), - "token_scores.json".into(), - "reference_sample.wav".into(), - ] { - if !dir.join(&file).is_file() { - return Err(format!("missing {}", dir.join(file).display())); - } - } - Ok(()) -} - -fn load_engine(dir: &Path, precision: Precision) -> Result { - let p = |name: &str| dir.join(name).to_string_lossy().into_owned(); - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(p(&model_file(precision, "lm_main"))); - cfg.model.pocket.lm_flow = Some(p(&model_file(precision, "lm_flow"))); - cfg.model.pocket.encoder = Some(p("encoder.onnx")); - cfg.model.pocket.decoder = Some(p(&model_file(precision, "decoder"))); - cfg.model.pocket.text_conditioner = Some(p("text_conditioner.onnx")); - cfg.model.pocket.vocab_json = Some(p("vocab.json")); - cfg.model.pocket.token_scores_json = Some(p("token_scores.json")); - cfg.model.pocket.voice_embedding_cache_capacity = 16; - cfg.model.num_threads = 1; - cfg.model.debug = false; - let inner = - OfflineTts::create(&cfg).ok_or_else(|| format!("failed to create {precision:?} engine"))?; - let wave = - Wave::read(&p("reference_sample.wav")).ok_or("failed to read reference_sample.wav")?; - Ok(Engine { - inner, - voice: Voice { - samples: wave.samples().to_vec(), - sample_rate: wave.sample_rate(), - }, - }) -} - -fn synth_chunks(engine: &Engine, chunks: &[String]) -> Result, String> { - let mut out = Vec::new(); - for chunk in chunks { - let prepared = prepare_pocket_prompt(chunk).ok_or("empty prepared prompt")?; - let extra = prepared.max_frames.map(|max_frames| { - HashMap::from([( - "max_frames".to_string(), - serde_json::Value::from(max_frames), - )]) - }); - let cfg = GenerationConfig { - num_steps: NUM_STEPS, - silence_scale: SILENCE_SCALE, - reference_audio: Some(engine.voice.samples.clone()), - reference_sample_rate: engine.voice.sample_rate, - extra, - ..Default::default() - }; - let audio = engine - .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| format!("synthesis failed for {chunk:?}"))?; - let mut samples: Vec = audio.samples().iter().map(|s| s.clamp(-1.0, 1.0)).collect(); - apply_fade_out(&mut samples); - out.extend(std::iter::repeat_n(0.0, LEAD_IN_SAMPLES)); - out.extend(samples); - out.extend(std::iter::repeat_n( - 0.0, - INTER_SENTENCE_SILENCE_SAMPLES - LEAD_IN_SAMPLES, - )); - } - Ok(out) -} - -fn apply_fade_out(samples: &mut [f32]) { - let fade = FADE_OUT_SAMPLES.min(samples.len() / 2); - for i in 0..fade { - samples[samples.len() - 1 - i] *= i as f32 / fade as f32; - } -} - -fn active_rms(samples: &[f32]) -> Option { - let (sum_squares, count) = samples - .iter() - .filter(|sample| sample.abs() > 1.0e-4) - .fold((0.0_f32, 0_usize), |(sum, count), sample| { - (sum + sample * sample, count + 1) - }); - (count > 0).then(|| (sum_squares / count as f32).sqrt()) -} - -/// Attenuate every clip in one comparison set to the quietest active-speech RMS. -/// This removes the louder-is-better confound without normalizing dynamics or -/// claiming standards-compliant integrated LUFS. The dBFS value is a ceiling. -fn loudness_match_item(rendered: &mut [(usize, Condition, Vec, u128)]) { - let ceiling = 10.0_f32.powf(TARGET_RMS_DBFS / 20.0); - let target = rendered - .iter() - .filter_map(|(_, _, samples, _)| active_rms(samples)) - .fold(ceiling, f32::min); - for (_, _, samples, _) in rendered { - let Some(rms) = active_rms(samples) else { - continue; - }; - let gain = (target / rms).min(1.0); - for sample in samples { - *sample *= gain; - } - } -} - -fn blinded_order(item_id: &str) -> [usize; 4] { - let mut keyed: Vec<(usize, Vec)> = (0..4) - .map(|index| { - let digest = Sha256::digest(format!("{BLINDING_SEED}:{item_id}:{index}")); - (index, digest.to_vec()) - }) - .collect(); - keyed.sort_by(|a, b| a.1.cmp(&b.1)); - let mut condition_to_clip = [0; 4]; - for (clip, (condition, _)) in keyed.into_iter().enumerate() { - condition_to_clip[condition] = clip; - } - condition_to_clip -} - -fn write_wav(path: &Path, samples: &[f32]) -> Result<(), String> { - let path = path - .to_str() - .ok_or_else(|| format!("non-UTF8 path: {}", path.display()))?; - if sherpa_onnx::write(path, samples, SAMPLE_RATE as i32) { - Ok(()) - } else { - Err(format!("failed to write {path}")) - } -} - -fn write_scoring_sheet(output_dir: &Path, key: &KeyFile) -> Result<(), String> { - let mut sheet = String::from("# Pocket TTS blind listening sheet\n\nDo not open `key.json` until this sheet is complete. Rank best to worst; ties are allowed.\n\n"); - for item in &key.items { - sheet.push_str(&format!( - "## {} ({})\n\n> {}\n\n", - item.id, item.kind, item.text - )); - sheet.push_str("Rank: `____ > ____ > ____ > ____`\n\n| Clip | seam | onset | garble | robotic | timbre | truncate | note |\n|---|---|---|---|---|---|---|---|\n"); - for clip in 1..=4 { - sheet.push_str(&format!( - "| clip{clip} | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | |\n" - )); - } - sheet.push('\n'); - } - fs::write(output_dir.join("SCORING.md"), sheet).map_err(|e| e.to_string()) -} diff --git a/desktop/src-tauri/src/huddle/models.rs b/desktop/src-tauri/src/huddle/models.rs index 169ddf66c0..11c9ee4c7d 100644 --- a/desktop/src-tauri/src/huddle/models.rs +++ b/desktop/src-tauri/src/huddle/models.rs @@ -24,6 +24,10 @@ use std::sync::{Arc, Mutex, OnceLock}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use super::pocket::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; + // ── Integrity verification ──────────────────────────────────────────────────── // // All model artifacts are verified against pinned SHA-256 hashes before @@ -38,19 +42,15 @@ use sha2::{Digest, Sha256}; /// Computed from a known-good download. Update when upgrading model versions. const STT_ARCHIVE_SHA256: &str = "17f945007b52ccd8b7200ffc7c5652e9e8e961dfdf479cefcabd06cf5703630b"; -/// HuggingFace base URL for the sherpa-onnx Pocket TTS fp32 repackage. -/// -/// Pinned to commit 96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3 -/// (2026-02-10) for reproducible downloads. -/// -/// fp32 (not int8): a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -/// found the ONNX int8 quantization audibly degraded Pocket TTS output and -/// that fp32 "significantly improved quality even at 1 step". The runtime -/// bundle grows from ~189 MB to ~473 MB; encoder, text conditioner, both -/// JSON tables, and LICENSE are byte-identical between the two repos — only -/// the three quantized sessions (lm_main, lm_flow, decoder) change. -const POCKET_HF_BASE: &str = - "https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26/resolve/96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3"; +fn pocket_artifact_url(filename: &str) -> String { + format!( + "https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/{APRIL_BUNDLE_ID}/{filename}" + ) +} + +fn pocket_license_url() -> String { + format!("https://huggingface.co/{APRIL_MODEL_ID}/resolve/{APRIL_MODEL_REVISION}/onnx/LICENSE") +} /// Reference voice WAV: "Mary (f, conversation)" from the Kyutai TTS demo /// voice set — VCTK speaker p333, ai-coustics-enhanced. Pinned to @@ -64,20 +64,19 @@ const POCKET_HF_BASE: &str = const POCKET_REFERENCE_WAV_URL: &str = "https://huggingface.co/kyutai/tts-voices/resolve/323332d33f997de8394f24a193e1a76df720e01a/vctk/p333_023_enhanced.wav"; -/// SHA-256 hashes for individual Pocket TTS model files. -/// Computed from known-good pinned downloads. Update when upgrading model versions. -#[rustfmt::skip] -const TTS_FILE_HASHES: &[(&str, &str)] = &[ - ("decoder.onnx", "f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10"), - ("encoder.onnx", "e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260"), - ("lm_flow.onnx", "79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e"), - ("lm_main.onnx", "255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578"), - ("text_conditioner.onnx", "0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1"), - ("vocab.json", "6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01"), - ("token_scores.json", "5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958"), - ("LICENSE", "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6"), - ("reference_sample.wav", "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f"), -]; +const TTS_LICENSE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "LICENSE", + sha256: "fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6", + size_bytes: 18_655, + quantized: false, +}; + +const TTS_REFERENCE_ARTIFACT: PocketModelArtifact = PocketModelArtifact { + filename: "reference_sample.wav", + sha256: "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + size_bytes: 639_084, + quantized: false, +}; // ── Model versioning ────────────────────────────────────────────────────────── // @@ -92,15 +91,8 @@ const TTS_FILE_HASHES: &[(&str, &str)] = &[ /// honest (each version tag identifies one specific set of model bytes). const STT_MODEL_VERSION: &str = "2"; -/// Model manifest version for Pocket TTS. Increment when upgrading model files. -/// Bumped "1" → "2" when the bundled reference voice changed from KevinAHM's -/// anonymous 16 kHz sample to Mary (VCTK p333, 32 kHz, ai-coustics-enhanced) -/// from kyutai/tts-voices. The hash mismatch on `reference_sample.wav` would -/// fail readiness on its own, but the manifest bump makes the re-download -/// reason explicit and skips the failing-then-re-fetching transient state. -/// Bumped "2" → "3" for the int8 → fp32 model swap (see `POCKET_HF_BASE`): -/// existing int8 installs must re-download the suffixless fp32 sessions. -const TTS_MODEL_VERSION: &str = "3"; +/// Identifies the exact April INT8 asset set expected by readiness checks. +const TTS_MODEL_VERSION: &str = "4"; /// Filename for the version manifest written alongside model files. const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; @@ -110,9 +102,9 @@ const MANIFEST_FILENAME: &str = ".buzz-model-manifest"; /// Maximum expected STT archive size (200 MB — actual is ~100 MB). const MAX_STT_DOWNLOAD_BYTES: u64 = 200 * 1024 * 1024; -/// Maximum expected Pocket TTS file size (400 MB per file — largest is -/// `lm_main.onnx` at ~303 MB fp32). -const MAX_TTS_FILE_BYTES: u64 = 400 * 1024 * 1024; +/// Maximum expected Pocket TTS file size. The largest pinned INT8 artifact is +/// `flow_lm_main_int8.onnx` at 76,341,079 bytes. +const MAX_TTS_FILE_BYTES: u64 = 100 * 1024 * 1024; /// NVIDIA Parakeet TDT-CTC 110M (English, int8) — packaged for sherpa-onnx by /// k2-fsa. Single ONNX file (CTC head) + tokens.txt. Avg WER ~7.5% across @@ -181,9 +173,9 @@ Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). Mimi neural codec by Kyutai is bundled as part of the model. -ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx -Sherpa-onnx repackage by csukuangfj / k2-fsa: -https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 +April 2026 ONNX export by KevinAHM: +https://huggingface.co/KevinAHM/pocket-tts-onnx +Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c Bundled reference voice (reference_sample.wav): \"Mary (f, conversation)\" preset from the Kyutai TTS demo voice catalogue @@ -203,13 +195,14 @@ license text for full warranty disclaimer. /// All files that must be present for Pocket TTS to be considered ready. const TTS_EXPECTED_FILES: &[&str] = &[ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", + "bundle.json", + "bos_before_voice.npy", + "flow_lm_main_int8.onnx", + "flow_lm_flow_int8.onnx", + "mimi_decoder_int8.onnx", + "mimi_encoder.onnx", "text_conditioner.onnx", - "vocab.json", - "token_scores.json", + "tokenizer.model", "LICENSE", "reference_sample.wav", TTS_LICENSE_FILE_NAME, @@ -404,6 +397,7 @@ struct ModelSlot { dir_name: &'static str, // subdir under ~/.buzz/models/ expected_files: &'static [&'static str], // files required for "ready" version: &'static str, // manifest version; increment to force re-download + expected_size: fn(&str) -> Option, status: Arc>, just_ready: Arc, // fires once when download completes } @@ -418,11 +412,17 @@ impl ModelSlot { dir_name, expected_files, version, + expected_size: |_| None, status: Arc::new(Mutex::new(ModelStatus::NotDownloaded)), just_ready: Arc::new(AtomicBool::new(false)), } } + fn with_expected_sizes(mut self, expected_size: fn(&str) -> Option) -> Self { + self.expected_size = expected_size; + self + } + fn model_dir(&self, models_dir: &Path) -> PathBuf { models_dir.join(self.dir_name) } @@ -432,7 +432,17 @@ impl ModelSlot { std::fs::read_to_string(dir.join(MANIFEST_FILENAME)) .map(|v| v.trim() == self.version) .unwrap_or(false) - && self.expected_files.iter().all(|f| dir.join(f).is_file()) + && self.expected_files.iter().all(|filename| { + let path = dir.join(filename); + path.is_file() + && (self.expected_size)(filename) + .map(|expected| { + path.metadata() + .map(|metadata| metadata.len() == expected) + .unwrap_or(false) + }) + .unwrap_or(true) + }) } fn dir_if_ready(&self, models_dir: &Path) -> Option { @@ -453,6 +463,39 @@ impl ModelSlot { self.just_ready.swap(false, Ordering::AcqRel) } + /// Recover or clean up the backup left by an interrupted atomic install. + fn recover_interrupted_install(&self, models_dir: &Path) { + let final_dir = self.model_dir(models_dir); + let backup_dir = final_dir.with_extension("old"); + if !backup_dir.exists() { + return; + } + if self.is_ready(models_dir) { + if let Err(error) = std::fs::remove_dir_all(&backup_dir) { + eprintln!( + "buzz-desktop: could not remove stale {} backup: {error}", + self.dir_name + ); + } + return; + } + if final_dir.exists() { + if let Err(error) = std::fs::remove_dir_all(&final_dir) { + eprintln!( + "buzz-desktop: could not remove incomplete {} install: {error}", + self.dir_name + ); + return; + } + } + if let Err(error) = std::fs::rename(&backup_dir, &final_dir) { + eprintln!( + "buzz-desktop: could not restore interrupted {} install: {error}", + self.dir_name + ); + } + } + /// Spawn a background download task if not already ready or downloading. fn start_download( &self, @@ -511,6 +554,9 @@ impl ModelSlot { )); } + std::fs::write(source_dir.join(MANIFEST_FILENAME), self.version) + .map_err(|e| format!("write model manifest: {e}"))?; + let final_dir = self.model_dir(models_dir); let backup_dir = final_dir.with_extension("old"); @@ -529,8 +575,6 @@ impl ModelSlot { return Err(format!("install new model: {e}")); } - std::fs::write(final_dir.join(MANIFEST_FILENAME), self.version) - .map_err(|e| format!("write model manifest: {e}"))?; let _ = tokio::fs::remove_dir_all(&backup_dir).await; if let Some(extra) = temp_cleanup { let _ = tokio::fs::remove_dir_all(extra).await; @@ -542,6 +586,25 @@ impl ModelSlot { } } +fn tts_expected_size(filename: &str) -> Option { + april_model_info() + .artifacts + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + .or_else(|| { + [TTS_LICENSE_ARTIFACT, TTS_REFERENCE_ARTIFACT] + .iter() + .find(|artifact| artifact.filename == filename) + .map(|artifact| artifact.size_bytes) + }) +} + +fn tts_model_slot() -> ModelSlot { + ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION) + .with_expected_sizes(tts_expected_size) +} + // ── ModelManager ────────────────────────────────────────────────────────────── /// Manages download and location of STT/TTS model files. @@ -561,11 +624,13 @@ impl ModelManager { /// Returns `None` if the home directory cannot be resolved. pub fn new() -> Option { let models_dir = dirs::home_dir()?.join(".buzz").join("models"); - Some(Self { + let manager = Self { models_dir, stt: ModelSlot::new(STT_MODEL_DIR_NAME, STT_EXPECTED_FILES, STT_MODEL_VERSION), - tts: ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION), - }) + tts: tts_model_slot(), + }; + manager.tts.recover_interrupted_install(&manager.models_dir); + Some(manager) } // ── STT accessors ──────────────────────────────────────────────────────── @@ -638,7 +703,7 @@ impl ModelManager { } } - /// Start a background Pocket TTS download (~189 MB). No-op if already ready or downloading. + /// Start a background Pocket TTS download. No-op if already ready or downloading. pub fn start_tts_download(&self, http_client: reqwest::Client) { let manager = self.clone(); self.tts.start_download( @@ -754,8 +819,8 @@ impl ModelManager { /// Download and verify the Pocket TTS model files from HuggingFace. /// /// Downloads files into `~/.buzz/models/pocket-tts/`: - /// - five ONNX sessions (Pocket TTS + Mimi codec) - /// - `vocab.json` / `token_scores.json` for sherpa-onnx text conditioning + /// - five ONNX sessions selected by the April INT8 bundle + /// - bundle metadata, SentencePiece tokenizer, and learned voice BOS /// - upstream `LICENSE` plus Buzz's `MODEL_LICENSE.txt` attribution sidecar /// - `reference_sample.wav` as the bundled default voice /// @@ -768,24 +833,18 @@ impl ModelManager { let temp_dir = self.models_dir.join("pocket-tts.tmp"); fresh_temp_dir(&temp_dir).await?; - let model_files = [ - "decoder.onnx", - "encoder.onnx", - "lm_flow.onnx", - "lm_main.onnx", - "text_conditioner.onnx", - "vocab.json", - "token_scores.json", - "LICENSE", - ]; - let mut downloads: Vec<(String, &'static str)> = model_files + let mut downloads: Vec<(String, PocketModelArtifact)> = april_model_info() + .artifacts .iter() - .map(|filename| (format!("{POCKET_HF_BASE}/{filename}"), *filename)) + .copied() + .map(|artifact| (pocket_artifact_url(artifact.filename), artifact)) .collect(); - downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), "reference_sample.wav")); + downloads.push((pocket_license_url(), TTS_LICENSE_ARTIFACT)); + downloads.push((POCKET_REFERENCE_WAV_URL.to_string(), TTS_REFERENCE_ARTIFACT)); let total_files = downloads.len() as u32; - for (i, (url, filename)) in downloads.iter().enumerate() { + for (i, (url, artifact)) in downloads.iter().enumerate() { + let filename = artifact.filename; eprintln!("buzz-desktop: downloading Pocket TTS {filename} from {url}"); let response = fetch_url(&http_client, url, filename) @@ -822,16 +881,19 @@ impl ModelManager { })?; eprintln!("buzz-desktop: downloaded {bytes} bytes ({filename}), wrote to disk"); - let expected = TTS_FILE_HASHES - .iter() - .find(|(n, _)| *n == *filename) - .map(|(_, hash)| *hash) - .ok_or_else(|| format!("missing expected hash for Pocket TTS file: {filename}"))?; + if bytes != artifact.size_bytes { + let _ = tokio::fs::remove_dir_all(&temp_dir).await; + return Err(format!( + "Pocket TTS {filename} size check failed: expected {} bytes, got {bytes}", + artifact.size_bytes + )); + } let actual = sha256_file(&dest).await?; - if actual != expected { + if actual != artifact.sha256 { let _ = tokio::fs::remove_dir_all(&temp_dir).await; return Err(format!( - "Pocket TTS {filename} integrity check failed: expected {expected}, got {actual}" + "Pocket TTS {filename} integrity check failed: expected {}, got {actual}", + artifact.sha256 )); } @@ -931,24 +993,5 @@ pub fn is_tts_ready() -> bool { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tts_readiness_requires_license_sidecar() { - let temp = tempfile::tempdir().expect("tempdir"); - let slot = ModelSlot::new(TTS_MODEL_DIR_NAME, TTS_EXPECTED_FILES, TTS_MODEL_VERSION); - let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); - std::fs::create_dir_all(&model_dir).expect("create model dir"); - - for file in TTS_EXPECTED_FILES { - std::fs::write(model_dir.join(file), b"test").expect("write expected file"); - } - std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); - - assert!(slot.is_ready(temp.path())); - - std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); - assert!(!slot.is_ready(temp.path())); - } -} +#[path = "models_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/huddle/models_tests.rs b/desktop/src-tauri/src/huddle/models_tests.rs new file mode 100644 index 0000000000..4bcb4081e0 --- /dev/null +++ b/desktop/src-tauri/src/huddle/models_tests.rs @@ -0,0 +1,148 @@ +use super::*; + +fn create_ready_model_dir(root: &Path) -> PathBuf { + let model_dir = root.join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in TTS_EXPECTED_FILES { + let path = model_dir.join(file); + let handle = std::fs::File::create(path).expect("create expected file"); + if let Some(size) = tts_expected_size(file) { + handle.set_len(size).expect("size expected file"); + } else { + std::fs::write(model_dir.join(file), b"test").expect("write expected file"); + } + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), TTS_MODEL_VERSION).expect("manifest"); + model_dir +} + +#[test] +fn expected_files_match_april_int8_metadata() { + let mut expected = april_model_info() + .artifacts + .iter() + .map(|artifact| artifact.filename) + .chain([ + TTS_LICENSE_ARTIFACT.filename, + TTS_REFERENCE_ARTIFACT.filename, + TTS_LICENSE_FILE_NAME, + ]) + .collect::>(); + expected.sort_unstable(); + let mut actual = TTS_EXPECTED_FILES.to_vec(); + actual.sort_unstable(); + + assert_eq!(actual, expected); + assert!(!actual.contains(&"flow_lm_main.onnx")); + assert!(!actual.contains(&"flow_lm_flow.onnx")); + assert!(!actual.contains(&"mimi_decoder.onnx")); +} + +#[test] +fn tts_readiness_requires_license_sidecar() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + + assert!(slot.is_ready(temp.path())); + + std::fs::remove_file(model_dir.join(TTS_LICENSE_FILE_NAME)).expect("remove sidecar"); + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn tts_readiness_rejects_truncated_pinned_artifact() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let artifact = april_model_info().artifacts[0]; + + std::fs::OpenOptions::new() + .write(true) + .open(model_dir.join(artifact.filename)) + .expect("open artifact") + .set_len(artifact.size_bytes - 1) + .expect("truncate artifact"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn january_cache_is_not_ready_for_april_int8() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + std::fs::create_dir_all(&model_dir).expect("create model dir"); + for file in [ + "decoder.onnx", + "encoder.onnx", + "lm_flow.onnx", + "lm_main.onnx", + "text_conditioner.onnx", + "vocab.json", + "token_scores.json", + "LICENSE", + "reference_sample.wav", + TTS_LICENSE_FILE_NAME, + ] { + std::fs::write(model_dir.join(file), b"january").expect("write January file"); + } + std::fs::write(model_dir.join(MANIFEST_FILENAME), "3").expect("manifest"); + + assert!(!slot.is_ready(temp.path())); +} + +#[test] +fn interrupted_install_restores_backup_when_destination_is_missing() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(temp.path().join(TTS_MODEL_DIR_NAME).join("sentinel")) + .expect("restored sentinel"), + b"previous" + ); + assert!(!backup_dir.exists()); +} + +#[test] +fn interrupted_install_replaces_incomplete_destination_with_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = temp.path().join(TTS_MODEL_DIR_NAME); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&model_dir).expect("create incomplete destination"); + std::fs::write(model_dir.join("incomplete"), b"april").expect("write incomplete file"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert_eq!( + std::fs::read(model_dir.join("sentinel")).expect("restored sentinel"), + b"previous" + ); + assert!(!model_dir.join("incomplete").exists()); + assert!(!backup_dir.exists()); +} + +#[test] +fn ready_destination_removes_stale_backup() { + let temp = tempfile::tempdir().expect("tempdir"); + let slot = tts_model_slot(); + let model_dir = create_ready_model_dir(temp.path()); + let backup_dir = temp.path().join("pocket-tts.old"); + std::fs::create_dir_all(&backup_dir).expect("create backup"); + std::fs::write(backup_dir.join("sentinel"), b"previous").expect("write sentinel"); + + slot.recover_interrupted_install(temp.path()); + + assert!(slot.is_ready(temp.path())); + assert!(model_dir.exists()); + assert!(!backup_dir.exists()); +} diff --git a/desktop/src-tauri/src/huddle/pocket.rs b/desktop/src-tauri/src/huddle/pocket.rs index ee1faf928a..2154a25c22 100644 --- a/desktop/src-tauri/src/huddle/pocket.rs +++ b/desktop/src-tauri/src/huddle/pocket.rs @@ -1,184 +1,52 @@ -//! Pocket TTS engine wrapper around sherpa-onnx's `OfflineTts`. +//! April 2026 Pocket TTS engine for Buzz Desktop. //! -//! Pocket TTS is a small (~473 MB fp32 ONNX) zero-shot voice-cloning TTS -//! model from Kyutai. It runs quickly on CPU via sherpa-onnx, replacing the -//! previous Kokoro-82M engine that also required an espeak-free but -//! lexicon-heavy G2P pipeline (Misaki + CMUdict). -//! -//! Full-precision fp32 sessions, not the ~189 MB int8 quantization we -//! originally shipped: a direct same-runtime A/B (k2-fsa/sherpa-onnx#3172) -//! found the int8 ONNX export audibly degraded output quality, and fp32 -//! "significantly improved quality even at 1 step". +//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned +//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding. +//! Buzz selects the upstream three-graph INT8 variant while retaining the +//! full-precision Mimi encoder and text conditioner specified by that variant. //! //! ## Attribution //! -//! - **Model**: Kyutai *Pocket TTS* — Charles, Roebel, et al., 2026. -//! arXiv:2509.06926. Original repository: . -//! Licensed CC-BY-4.0. -//! - **Mimi neural codec**: Kyutai, bundled in the same release. CC-BY-4.0. -//! - **ONNX export**: KevinAHM — -//! . CC-BY-4.0. -//! - **sherpa-onnx repackage**: csukuangfj / k2-fsa — -//! . -//! Repackages KevinAHM's export with the file layout sherpa-onnx's -//! `OfflineTtsPocketModelConfig` expects. CC-BY-4.0. -//! - **Reference voice WAV** (`reference_sample.wav`): the "Mary -//! (f, conversation)" preset from the Kyutai TTS demo -//! (), which maps to `vctk/p333_023_enhanced.wav` -//! in . CC-BY-4.0, base recording -//! from the VCTK corpus, enhanced by ai-coustics. -//! -//! Buzz ships these files unmodified; see the on-disk `MODEL_LICENSE.txt` -//! sidecar written by `huddle::models` during install for the canonical -//! CC-BY-4.0 §3(a)(1) attribution block. -//! -//! ## Engine-module contract (see `huddle::tts`) +//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0. +//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0. +//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0. //! -//! `pocket.rs` exposes a fixed surface used by `tts.rs`. Mirroring this -//! contract is what lets the TTS pipeline stay engine-agnostic: -//! -//! - `SAMPLE_RATE: u32` — engine output sample rate in Hz. -//! - `DEFAULT_VOICE: &str` — default voice name (without extension). -//! - `VOICE_FILE_EXT: &str` — extension for per-voice files on disk. -//! - `load_text_to_speech(model_dir)` → `Result` -//! - `load_voice_style(path)` → `Result` -//! - `Engine::synth_chunk(&self, text, lang, &VoiceStyle, steps)` -//! → `Result, String>` -//! -//! `lang` and `steps` are accepted for API compatibility with the previous -//! Kokoro engine but are unused — Pocket TTS does its own language ID from -//! the input text and is not a diffusion model (consistency LM, one step). -//! There is no speed knob: sherpa-onnx's `GenerationConfig.speed` is only -//! read by some model families (vits), never by the Pocket impl -//! (`offline-tts-pocket-impl.h` — zero references), and upstream pocket-tts -//! has no speed parameter either. +//! `huddle::models` writes the complete attribution beside the cached bytes. -use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use sherpa_onnx::Wave; -use sherpa_onnx::{GenerationConfig, OfflineTts, OfflineTtsConfig, Wave}; +#[path = "pocket_april.rs"] +mod pocket_april; +#[path = "pocket_models.rs"] +mod pocket_models; -// ── Engine-module contract: public consts ───────────────────────────────────── +use pocket_april::{prepare_april_prompt, AprilPocketTts}; +pub(crate) use pocket_models::{ + april_model_info, PocketModelArtifact, APRIL_BUNDLE_ID, APRIL_MODEL_ID, APRIL_MODEL_REVISION, +}; -/// Pocket TTS emits 24 kHz mono PCM. Matches the previous Kokoro output rate, -/// so the rodio sink and inter-sentence silence buffer in `tts.rs` remain valid. +/// Pocket TTS emits 24 kHz mono PCM. pub const SAMPLE_RATE: u32 = 24_000; -/// Name (without extension) of the bundled reference voice. The model directory -/// is expected to contain `.` after install. +/// Bundled reference voice name without its extension. pub const DEFAULT_VOICE: &str = "reference_sample"; -/// Voice files for Pocket TTS are reference audio (WAV). Distinct from the -/// Kokoro `.bin` style vectors — the model conditions on raw waveform samples, -/// not a precomputed embedding, so the extension change is honest. +/// Pocket voice files are reference WAVs. pub const VOICE_FILE_EXT: &str = "wav"; -// ── Tuning ──────────────────────────────────────────────────────────────────── - -/// Single-threaded ONNX execution for predictable CPU contention with the STT -/// pipeline. Matches `STT_NUM_THREADS` in `stt.rs`; raise only if a benchmark -/// argues for it. -const TTS_NUM_THREADS: i32 = 1; - -/// LRU cache size for cloned voice embeddings inside the sherpa-onnx engine. -/// We bind to one voice per pipeline today, but the upstream example uses 16 -/// and the cost is negligible — keep room for future multi-voice support. -const VOICE_EMBEDDING_CACHE_CAPACITY: i32 = 16; - -/// Pocket TTS is a consistency-based LM. Generation quality saturates at one -/// denoising step — the upstream `GenerationConfig` default of 5 multiplies -/// synthesis time by ~5× with no audible benefit on this model. -const SYNTH_NUM_STEPS: i32 = 1; - -/// Leave the generated audio's silences untouched (1.0 is the identity). -/// -/// sherpa-onnx's `ScaleSilence` (`offline-tts.cc`) is *not* pre/post padding -/// control: it finds every interior silence run ≥ 0.2 s (|s| ≤ 0.01) and -/// multiplies its length by this factor. The previous value of 0.0 — set -/// under the mistaken belief it disabled lead-in/lead-out padding — deleted -/// every natural pause inside an utterance: clause breaks, breaths, the gap -/// after a comma. Words slammed together and endings cut abruptly. The -/// reference Pocket TTS pipeline does not post-process silence at all; -/// 1.0 restores parity. -const SYNTH_SILENCE_SCALE: f32 = 1.0; - -/// sherpa-onnx upstream default for `max_frames` (LM steps), in -/// `offline-tts-pocket-impl.h:Generate`. 500 steps ≈ 40 s of audio at the -/// Mimi 12.5 Hz frame rate. Referenced only by the regression test below; -/// production code path never raises (or even reads) this value — we just -/// leave sherpa-onnx's own default in place by not setting the override. -#[cfg(test)] -const SHERPA_ONNX_MAX_FRAMES_DEFAULT: i32 = 500; - -/// Tight `max_frames` we ask for on short, padded prompts to bound the -/// original "monster breathing" runaway. 100 LM steps ≈ 8 s of audio — -/// roomy for any one-to-four-word utterance the user is likely to elicit -/// while still well short of the 40 s upstream default. Chosen with slack so -/// we never *truncate* a legitimate short reply. -const SHORT_PROMPT_MAX_FRAMES: i32 = 100; - -/// Word-count threshold (inclusive) below which we pad the prompt with -/// leading spaces and cap `max_frames` tighter than the upstream default. -/// Matches upstream `pocket_tts.models.tts_model.prepare_text_prompt`. Above -/// this threshold we leave sherpa-onnx's own defaults in place — overriding -/// them caused the "first 'yep' is just static" regression seen on -/// 2026-05-18, where dropping `frames_after_eos` below the upstream default -/// of 3 clipped the leading audio of multi-clause sentences. -const SHORT_PROMPT_WORD_THRESHOLD: usize = 4; - -/// Number of leading spaces prepended to short prompts. The upstream Python -/// uses exactly 8 — keep parity rather than tuning blindly. -/// -/// This is upstream's *only* mitigation for the FlowLM cold-start smear on -/// short utterances (kyutai-labs/pocket-tts #91, #70): the autoregressive -/// generation has a 2–3 step "settle" period where the first phoneme can be -/// smeared. A previous revision added a sacrificial `". . "` prefix plus an -/// amplitude-threshold trim to strip the rendered prefix from the output — -/// but the trim's absolute threshold (0.02 against raw peaks of ~0.076) sat -/// in soft-onset territory and could eat real word starts, and its tuning -/// was calibrated against `silence_scale = 0.0` audio. Deleted in favour of -/// upstream parity: accept the occasional smeared first syllable rather -/// than risk trimming real speech. -const SHORT_PROMPT_PAD_SPACES: usize = 8; - -/// sherpa-onnx's documented `frames_after_eos` default. We deliberately do -/// *not* override this knob — the previous attempt to bump it for short -/// inputs and lower it for long inputs lowered it below the upstream default -/// of 3, which clipped the leading audio of multi-clause sentences (the -/// "first 'yep' is static" regression). The constant exists only for the -/// regression test below. Source: `offline-tts-pocket-impl.h:Generate`. -#[cfg(test)] -const SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT: i32 = 3; - -// ── ONNX file names (five Pocket TTS sessions plus two JSON tables) ─────────── +const TTS_NUM_THREADS: usize = 1; -const FILE_LM_MAIN: &str = "lm_main.onnx"; -const FILE_LM_FLOW: &str = "lm_flow.onnx"; -const FILE_ENCODER: &str = "encoder.onnx"; -const FILE_DECODER: &str = "decoder.onnx"; -const FILE_TEXT_COND: &str = "text_conditioner.onnx"; -const FILE_VOCAB: &str = "vocab.json"; -const FILE_TOKEN_SCORES: &str = "token_scores.json"; - -// ── Voice style ─────────────────────────────────────────────────────────────── - -/// Loaded reference voice — normalised f32 PCM samples plus their sample rate. -/// -/// Pocket TTS takes a reference waveform per generation call (not a -/// precomputed style embedding), so we keep the samples in memory and clone -/// the small `Vec` into each `GenerationConfig` rather than re-reading the -/// WAV from disk on every sentence. +/// Loaded reference voice samples and their original sample rate. #[derive(Debug, Clone)] pub struct VoiceStyle { samples: Vec, sample_rate: i32, } -/// Load a reference voice WAV from disk. -/// -/// Accepts any sample rate sherpa-onnx's `Wave::read` can decode — Pocket TTS -/// resamples internally using `reference_sample_rate`. The bundled -/// `reference_sample.wav` ("Mary" — VCTK p333, enhanced) is 32 kHz mono. +/// Load a Pocket reference voice WAV from disk. pub fn load_voice_style(path: &Path) -> Result { let path_str = path .to_str() @@ -195,199 +63,46 @@ pub fn load_voice_style(path: &Path) -> Result { }) } -// ── Engine ──────────────────────────────────────────────────────────────────── - -/// Pocket TTS engine handle. Cheap to construct (one `OfflineTts::create` -/// call). Owned by the TTS worker thread for the lifetime of a huddle session. -/// -/// `OfflineTts` does not implement `Debug`, so we don't derive it here — the -/// pipeline only needs to move the engine into the worker thread and call -/// `synth_chunk` on it, never to print it. +/// Resident April INT8 Pocket TTS engine. pub struct PocketTts { - inner: OfflineTts, + inner: Mutex, } -/// Build the Pocket TTS engine from the model directory installed by -/// `huddle::models`. Returns `Err` if any expected ONNX or JSON file is -/// missing — readiness is normally enforced by `is_tts_ready` upstream, but -/// the check is repeated here so a manually-modified model dir produces a -/// clear error string instead of an opaque sherpa-onnx `None`. +/// Load Buzz Desktop's pinned April INT8 model. pub fn load_text_to_speech(model_dir: &str) -> Result { let dir = PathBuf::from(model_dir); - for name in [ - FILE_LM_MAIN, - FILE_LM_FLOW, - FILE_ENCODER, - FILE_DECODER, - FILE_TEXT_COND, - FILE_VOCAB, - FILE_TOKEN_SCORES, - ] { - let p = dir.join(name); - if !p.is_file() { - return Err(format!("missing Pocket TTS file: {}", p.display())); - } - } - - let to_str = |name: &str| -> String { dir.join(name).to_string_lossy().into_owned() }; - - // Build the config by mutating defaults — mirrors `stt.rs` and stays - // resilient if sherpa-onnx adds unrelated model-family fields. - let mut cfg = OfflineTtsConfig::default(); - cfg.model.pocket.lm_main = Some(to_str(FILE_LM_MAIN)); - cfg.model.pocket.lm_flow = Some(to_str(FILE_LM_FLOW)); - cfg.model.pocket.encoder = Some(to_str(FILE_ENCODER)); - cfg.model.pocket.decoder = Some(to_str(FILE_DECODER)); - cfg.model.pocket.text_conditioner = Some(to_str(FILE_TEXT_COND)); - cfg.model.pocket.vocab_json = Some(to_str(FILE_VOCAB)); - cfg.model.pocket.token_scores_json = Some(to_str(FILE_TOKEN_SCORES)); - cfg.model.pocket.voice_embedding_cache_capacity = VOICE_EMBEDDING_CACHE_CAPACITY; - cfg.model.num_threads = TTS_NUM_THREADS; - // Explicit — defaults are not part of the API contract, and noisy debug - // logging in release builds would be expensive on every synthesized chunk. - cfg.model.debug = false; - - let inner = OfflineTts::create(&cfg) - .ok_or_else(|| "OfflineTts::create returned None for Pocket TTS".to_string())?; - Ok(PocketTts { inner }) -} - -// ── Prompt preparation ──────────────────────────────────────────────────────── - -/// Result of [`prepare_pocket_prompt`]: a synthesizer-ready prompt plus the -/// per-call generation overrides derived from the original text. -/// -/// `None` for either override means "leave sherpa-onnx's documented default -/// in place". The pipeline only sets `max_frames` (and only for short -/// padded inputs) so it can bound the original "monster breathing" runaway -/// without disturbing the rest of the LM sampling envelope. -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct PreparedPrompt { - /// Text to hand to `OfflineTts::generate_with_config`. Capitalized, - /// punctuation-terminated, and (for short inputs) left-padded with - /// spaces — upstream's mitigation for the FlowLM cold-start smear. - pub text: String, - /// Value to pass via `GenerationConfig.extra["max_frames"]`, or `None` to - /// keep the upstream default of 500 LM steps. We only override on short - /// padded prompts where we have a tight expectation on output length. - pub max_frames: Option, -} - -/// Mirror of the *text-preparation* half of upstream -/// `pocket_tts.models.tts_model.prepare_text_prompt`. Sherpa-onnx's C++ -/// Pocket TTS impl does not run these preparation steps, so short / -/// unpunctuated / lowercase inputs can trigger up to 40 s of runaway -/// generation when the EOS logit never crosses its threshold. We replicate -/// the upstream Python recipe here: -/// -/// 1. Collapse interior whitespace (already done by `preprocess_for_tts`, but -/// cheap to re-check after sentence splitting). -/// 2. Capitalize the first letter. -/// 3. Append `.` if the text doesn't end in punctuation. -/// 4. If fewer than five words, prepend `SHORT_PROMPT_PAD_SPACES` spaces -/// (upstream's cold-start mitigation — see the constant's docstring) and -/// return a tight [`SHORT_PROMPT_MAX_FRAMES`] cap so the LM can't run -/// away if EOS still doesn't fire. -/// -/// We do **not** override `frames_after_eos` — sherpa-onnx's default of 3 -/// is what we want. An earlier version set it to 1 on long inputs, which -/// clipped the leading audio of multi-clause sentences ("first 'yep' is -/// just static" regression). Tests `prepare_prompt_never_lowers_frames_…` -/// lock this in. -/// -/// Returns `None` only if the input is empty after trimming — caller should -/// skip synthesis in that case. -pub(crate) fn prepare_pocket_prompt(input: &str) -> Option { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; - } - - // Collapse stray double-spaces / embedded newlines that may slip past - // `preprocess_for_tts` when sentences are spliced back together. - let mut cleaned = String::with_capacity(trimmed.len()); - let mut last_was_space = false; - for ch in trimmed.chars() { - let is_ws = ch.is_whitespace(); - if is_ws { - if !last_was_space { - cleaned.push(' '); - } - last_was_space = true; - } else { - cleaned.push(ch); - last_was_space = false; + for artifact in april_model_info().artifacts { + let path = dir.join(artifact.filename); + if !path.is_file() { + return Err(format!( + "incomplete Pocket TTS {} INT8 bundle: missing {}", + APRIL_BUNDLE_ID, + path.display() + )); } } - - // Capitalize first character. Uses `to_uppercase` (multi-codepoint safe). - let first = cleaned.chars().next().expect("cleaned non-empty above"); - if first.is_lowercase() { - let upper: String = first.to_uppercase().collect(); - let mut iter = cleaned.chars(); - iter.next(); - cleaned = upper + iter.as_str(); - } - - // Ensure terminal punctuation. Anything not in `.!?;:,` gets a period. - // The upstream Python only checks `isalnum` → period, but for our agent - // text we already may end in `!` `?` `.` etc. — treat any of those as OK. - let last = cleaned - .chars() - .next_back() - .expect("cleaned non-empty above"); - if !matches!(last, '.' | '!' | '?' | ';' | ':' | ',') { - cleaned.push('.'); - } - - // Word count of the *cleaned but not padded* text — padding is whitespace - // only and would just lie to the threshold check below. - let word_count = cleaned.split_whitespace().count(); - - let (final_text, max_frames) = if word_count <= SHORT_PROMPT_WORD_THRESHOLD { - let mut padded = String::with_capacity(cleaned.len() + SHORT_PROMPT_PAD_SPACES); - for _ in 0..SHORT_PROMPT_PAD_SPACES { - padded.push(' '); - } - padded.push_str(&cleaned); - (padded, Some(SHORT_PROMPT_MAX_FRAMES)) - } else { - // For everything ≥5 words, fall back to upstream defaults. Overriding - // these is what caused the "first 'yep' is static" regression — the - // upstream LM has been tuned for `frames_after_eos = 3` and - // `max_frames = 500`, and there's no clear win in second-guessing. - (cleaned, None) - }; - - Some(PreparedPrompt { - text: final_text, - max_frames, - }) -} - -/// Build the `GenerationConfig.extra` HashMap from a [`PreparedPrompt`]. -/// -/// Centralised so the regression test below can assert that we **never** -/// emit a `frames_after_eos` override — the previous attempt to override -/// that knob (setting it to 1 for ≥5-word inputs) clipped the leading -/// audio of multi-clause sentences (the "first 'yep' is static" bug on -/// 2026-05-18). The upstream sherpa-onnx default of 3 is what we want, and -/// the right way to keep it is to not set it at all. -fn build_generation_extra(prepared: &PreparedPrompt) -> Option> { - prepared.max_frames.map(|mf| { - let mut h: HashMap = HashMap::with_capacity(1); - h.insert("max_frames".to_string(), serde_json::Value::from(mf)); - h + Ok(PocketTts { + inner: Mutex::new(AprilPocketTts::load(&dir, TTS_NUM_THREADS)?), }) } impl PocketTts { - /// Synthesise `text` with the given reference voice. + /// Split text into synthesis units that satisfy the bundle's exact + /// 50-token input limit. + pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + self.inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? + .split_prompt(&prepared) + } + + /// Synthesize text with the supplied reference voice. /// - /// `_lang` and `_steps` are accepted for API compatibility with the - /// previous Kokoro engine. Pocket TTS infers language from the input text - /// directly and is a one-step consistency model. Returns an empty buffer - /// for whitespace-only input. + /// Pocket detects language from text and this model uses one synthesis + /// step, so `_lang` and `_steps` intentionally do not affect output. pub fn synth_chunk( &self, text: &str, @@ -395,57 +110,21 @@ impl PocketTts { style: &VoiceStyle, _steps: usize, ) -> Result, String> { - // Mirror upstream pocket-tts prompt prep — without this short or - // unpunctuated inputs can cause the LM's EOS logit to never trip, - // producing up to 40 s of "monster breathing" garbage on the first - // utterance. See `prepare_pocket_prompt` for the full recipe. - let prepared = match prepare_pocket_prompt(text) { - Some(p) => p, - None => return Ok(Vec::new()), + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); }; - - // Per-call generation hints sherpa-onnx forwards to - // `offline-tts-pocket-impl.h`. We only override `max_frames`, and - // only for short padded prompts where we have a tight expectation - // on output length — that bounds the original runaway without - // disturbing the rest of the LM sampling envelope. See - // `prepare_pocket_prompt` docs for the regression history. - let extra = build_generation_extra(&prepared); - - let cfg = GenerationConfig { - num_steps: SYNTH_NUM_STEPS, - silence_scale: SYNTH_SILENCE_SCALE, - reference_audio: Some(style.samples.clone()), - reference_sample_rate: style.sample_rate, - extra, - // `speed` stays at its default: the Pocket impl never reads it - // (see the engine-contract note in the module docs). - ..Default::default() - }; - - // No progress callback — synthesis is fast enough that returning the - // whole buffer at once keeps the lookahead pipelining in `tts.rs` - // simple. `None:: bool>` pins the callback type for the - // `generate_with_config` generic parameter. - let audio = self + let mut engine = self .inner - .generate_with_config(&prepared.text, &cfg, None:: bool>) - .ok_or_else(|| { - format!( - "Pocket TTS synthesis failed for text ({} chars)", - prepared.text.len() - ) - })?; - - let sample_rate = audio.sample_rate(); - if sample_rate != SAMPLE_RATE as i32 { - eprintln!( - "buzz-desktop: Pocket TTS returned unexpected sample rate {sample_rate}Hz \ - (expected {SAMPLE_RATE}Hz); playback speed may be wrong" - ); + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?; + let chunks = engine.split_prompt(&prepared)?; + let mut samples = Vec::new(); + for chunk in chunks { + let prepared = prepare_april_prompt(&chunk) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + samples.extend(engine.synth_chunk(&prepared, style)?); } - - Ok(audio.samples().to_vec()) + Ok(samples) } } @@ -453,202 +132,35 @@ impl PocketTts { mod tests { use super::*; - // ── prepare_pocket_prompt ──────────────────────────────────────────────── - - #[test] - fn prepare_prompt_returns_none_for_empty_input() { - assert!(prepare_pocket_prompt("").is_none()); - assert!(prepare_pocket_prompt(" ").is_none()); - assert!(prepare_pocket_prompt("\n\t ").is_none()); - } - - /// Helper: the exact leading sequence prepended to every short prompt — - /// 8 spaces of padding (upstream's cold-start mitigation). - /// Centralising this keeps the assertions readable. - fn short_prefix() -> String { - " ".repeat(SHORT_PROMPT_PAD_SPACES) - } - - #[test] - fn prepare_prompt_pads_and_capitalizes_one_word() { - // The "yep" case Tyler hit in production — bare lowercase one-word - // utterance with no punctuation. Must be padded with the short-prompt - // space pad, capitalized, terminated, with a tight `max_frames` cap - // to bound runaway gen. - let out = prepare_pocket_prompt("yep").expect("non-empty"); - assert_eq!(out.text, format!("{}Yep.", short_prefix())); - assert_eq!(out.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - const { - assert!( - SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT, - "short cap must be tighter than the upstream default" - ); - } - } - - #[test] - fn prepare_prompt_preserves_existing_punctuation() { - let out = prepare_pocket_prompt("yes!").expect("non-empty"); - assert_eq!(out.text, format!("{}Yes!", short_prefix())); // exclamation kept - let out = prepare_pocket_prompt("really?").expect("non-empty"); - assert_eq!(out.text, format!("{}Really?", short_prefix())); - } - #[test] - fn prepare_prompt_threshold_is_inclusive_at_four_words() { - // 4 words = short (padded + tight max_frames); 5 words = long - // (no padding, no overrides — upstream defaults stand). - let four = prepare_pocket_prompt("one two three four").expect("non-empty"); - assert_eq!( - four.text, - format!("{}One two three four.", short_prefix()), - "four-word input should get exactly the space pad" - ); - assert_eq!(four.max_frames, Some(SHORT_PROMPT_MAX_FRAMES)); - - let five = prepare_pocket_prompt("one two three four five").expect("non-empty"); - assert!( - !five.text.starts_with(' '), - "five-word input should NOT be padded" - ); - assert_eq!( - five.max_frames, None, - "long inputs must leave sherpa-onnx's max_frames default in place" - ); - } - - #[test] - fn prepare_prompt_does_not_pad_long_text() { - let long = "This is a longer sentence that the model should handle just fine."; - let out = prepare_pocket_prompt(long).expect("non-empty"); - assert!(!out.text.starts_with(' ')); - assert_eq!(out.max_frames, None); - assert!(out.text.ends_with('.')); + fn desktop_model_is_april_int8_only() { + let info = april_model_info(); + assert_eq!(info.max_token_per_chunk, 50); + assert_eq!(info.sample_rate, SAMPLE_RATE); + assert!(info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main_int8.onnx")); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "flow_lm_main.onnx")); } #[test] - fn prepare_prompt_collapses_whitespace() { - let out = prepare_pocket_prompt("Hello world\n\nfriend").expect("non-empty"); - // 3 words → short → padded. Interior whitespace collapsed. - assert_eq!(out.text, format!("{}Hello world friend.", short_prefix())); - } - - #[test] - fn prepare_prompt_does_not_double_capitalize_already_uppercase() { - let out = prepare_pocket_prompt("HELLO there").expect("non-empty"); - assert_eq!(out.text, format!("{}HELLO there.", short_prefix())); - } - - #[test] - fn prepare_prompt_handles_non_ascii_first_letter() { - // Cyrillic lowercase 'д' → uppercase 'Д'. Must not panic / produce - // mojibake. - let out = prepare_pocket_prompt("дa").expect("non-empty"); - assert!(out.text.contains("Дa.")); - } - - /// REGRESSION GUARD: short prompts must receive *only* whitespace - /// padding — no sacrificial text. A previous revision prepended a - /// `". . "` cold-start absorber and trimmed the rendered audio back out - /// with an amplitude threshold that could eat soft word onsets. If - /// non-whitespace ever reappears in the pad, the synth output will - /// contain audio for text the user never wrote. - #[test] - fn prepare_prompt_pad_is_whitespace_only() { - let out = prepare_pocket_prompt("I'm happy.").expect("non-empty"); - let pad_len = out.text.len() - "I'm happy.".len(); - assert!( - out.text[..pad_len].chars().all(|c| c == ' '), - "short-prompt pad must be spaces only, got {:?}", - &out.text[..pad_len] - ); - assert_eq!(out.text, format!("{}I'm happy.", short_prefix())); - } - - // ── build_generation_extra ─────────────────────────────────────────────── - // - // These tests pin down a behaviour we've now regressed twice on: - // 1) Not padding/punctuating short inputs → 40 s of "monster breathing" - // (pre-773a2a1). - // 2) Setting `frames_after_eos = 1` on long inputs → clipped leading - // audio of multi-clause sentences, e.g. "Yep, I can hear you. …" - // came out as a static burst (the 773a2a1 regression Tyler hit on - // 2026-05-18 ~14:30 UTC). - // - // The contract we enforce going forward: we **only** override - // `max_frames`, and only for ≤4-word inputs. Every other knob is left - // at sherpa-onnx's documented default (notably `frames_after_eos = 3`). - - #[test] - fn build_extra_short_prompt_sets_only_max_frames() { - let prepared = prepare_pocket_prompt("yep").expect("non-empty"); - let extra = build_generation_extra(&prepared).expect("short prompts get extra"); - // Exactly one key — `max_frames` — and nothing else. - assert_eq!(extra.len(), 1, "extra has unexpected keys: {extra:?}"); - assert_eq!( - extra.get("max_frames"), - Some(&serde_json::Value::from(SHORT_PROMPT_MAX_FRAMES)) - ); - assert!( - !extra.contains_key("frames_after_eos"), - "frames_after_eos must never be set — upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT} is what we want" - ); - } - - #[test] - fn build_extra_long_prompt_is_none() { - // ≥5 words: no extras at all. This is the key fix for the "first - // 'yep' in 'Yep, I can hear you. …' is static" regression — we - // were previously forcing `frames_after_eos = 1` on this path. - let prepared = prepare_pocket_prompt("Yep, I can hear you.").expect("non-empty"); - assert_eq!( - build_generation_extra(&prepared), - None, - "long prompts must not override any LM knob" - ); - } - - #[test] - fn build_extra_never_lowers_frames_after_eos_for_any_word_count() { - // Sweep a range of prompt lengths and assert the `extra` map (when - // present) never carries a `frames_after_eos` override that's lower - // than the upstream sherpa-onnx default. Implemented as a structural - // check — we just never set the key — but worth a property test in - // case someone reintroduces the override in the future. - let prompts: &[&str] = &[ - "hi", - "hi there", - "yes please", - "one two three four", - "one two three four five", - "a slightly longer reply, hopefully fine", - "This is a multi-clause sentence. It has two parts.", - "really really really really really long prompt with lots of words just to be sure", - ]; - for &p in prompts { - let prepared = prepare_pocket_prompt(p).expect("non-empty"); - if let Some(extra) = build_generation_extra(&prepared) { - if let Some(v) = extra.get("frames_after_eos") { - let n = v.as_i64().expect("frames_after_eos should be int"); - assert!( - n >= SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT as i64, - "prompt {p:?} set frames_after_eos={n}, below upstream default of {SHERPA_ONNX_FRAMES_AFTER_EOS_DEFAULT}" - ); - } - } - } - } - - #[test] - fn short_prompt_max_frames_is_below_upstream_default() { - // Sanity: the override only ever *lowers* the cap, never raises it. - const { - assert!(SHORT_PROMPT_MAX_FRAMES < SHERPA_ONNX_MAX_FRAMES_DEFAULT); - } - // …and is still large enough for a one-to-four-word reply. At Mimi's - // 12.5 Hz frame rate, 100 frames = 8 s, which is roomy. - const { - assert!(SHORT_PROMPT_MAX_FRAMES >= 50, "would risk truncation"); - } + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn production_api_emits_non_silent_april_int8_pcm() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory"); + let engine = load_text_to_speech(&dir).expect("load April INT8 engine"); + let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav")) + .expect("load reference voice"); + let samples = engine + .synth_chunk("Bright birds begin beside the bay.", "en", &style, 1) + .expect("synthesize through the production API"); + + assert!(!samples.is_empty()); + assert!(samples.iter().all(|sample| sample.is_finite())); + assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6)); } } diff --git a/desktop/src-tauri/src/huddle/pocket_april.rs b/desktop/src-tauri/src/huddle/pocket_april.rs new file mode 100644 index 0000000000..43826df5c9 --- /dev/null +++ b/desktop/src-tauri/src/huddle/pocket_april.rs @@ -0,0 +1,940 @@ +//! Native ONNX loader for Pocket TTS `english_2026-04`. +//! +//! The bundle uses SentencePiece, prepends a learned BOS voice embedding, and +//! describes recurrent state tensors in `bundle.json`. This module supplies +//! that frontend and state loop while reusing the ONNX Runtime linked by the +//! Desktop speech stack. + +use std::borrow::Cow; +use std::f32::consts::TAU; +use std::fs; +use std::path::{Path, PathBuf}; + +use ort::session::{Session, SessionInputValue}; +use ort::value::{DynValue, Tensor}; +use rand::{Rng, RngExt}; +use sentencepiece_model::SentencePieceModel; +use serde::Deserialize; +use sherpa_onnx::LinearResampler; +use tokenizers::models::unigram::Unigram; +use tokenizers::pre_tokenizers::metaspace::{Metaspace, PrependScheme}; +use tokenizers::Tokenizer; + +use super::VoiceStyle; + +const FILE_BUNDLE: &str = "bundle.json"; +const FILE_MIMI_ENCODER: &str = "mimi_encoder.onnx"; +const FILE_TEXT_CONDITIONER: &str = "text_conditioner.onnx"; +const FILE_FLOW_MAIN_INT8: &str = "flow_lm_main_int8.onnx"; +const FILE_FLOW_INT8: &str = "flow_lm_flow_int8.onnx"; +const FILE_MIMI_DECODER_INT8: &str = "mimi_decoder_int8.onnx"; + +const MODEL_LANGUAGE: &str = "english_2026-04"; +const DEFAULT_TEMPERATURE: f32 = 0.7; +const EOS_LOGIT_THRESHOLD: f32 = -4.0; +const DECODER_CHUNK_FRAMES: usize = 12; +const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0; +const GENERATION_SECONDS_PADDING: f32 = 2.0; + +#[derive(Debug, Deserialize)] +struct Bundle { + schema_version: u32, + language: String, + sample_rate: usize, + frame_rate: f32, + samples_per_frame: usize, + latent_dim: usize, + conditioning_dim: usize, + insert_bos_before_voice: bool, + pad_with_spaces_for_short_inputs: bool, + remove_semicolons: bool, + model_recommended_frames_after_eos: Option, + max_token_per_chunk: usize, + tokenizer_file: String, + bos_before_voice_file: String, + flow_lm_state_manifest: Vec, + mimi_state_manifest: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +struct StateSpec { + input_name: String, + output_name: String, + dtype: StateDtype, + shape: Vec, + fill: StateFill, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateDtype { + #[serde(rename = "float32")] + Float32, + #[serde(rename = "int64")] + Int64, + Bool, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "lowercase")] +enum StateFill { + Empty, + Nan, + Ones, + Zeros, +} + +struct StateValue { + spec: StateSpec, + value: DynValue, +} + +struct CachedVoice { + samples_ptr: usize, + samples_len: usize, + sample_rate: i32, + embeddings: Vec, +} + +pub(crate) struct AprilPocketTts { + bundle: Bundle, + tokenizer: Tokenizer, + bos_embedding: Vec, + mimi_encoder: Session, + text_conditioner: Session, + flow_main: Session, + flow: Session, + mimi_decoder: Session, + cached_voice: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AprilPreparedPrompt { + pub(crate) text: String, + pub(crate) frames_after_eos: usize, +} + +pub(crate) fn prepare_april_prompt(input: &str) -> Option { + let trimmed = input.trim(); + if trimmed.is_empty() { + return None; + } + + let mut cleaned = String::with_capacity(trimmed.len()); + let mut last_was_space = false; + for ch in trimmed.chars() { + if ch.is_whitespace() { + if !last_was_space { + cleaned.push(' '); + } + last_was_space = true; + } else { + cleaned.push(ch); + last_was_space = false; + } + } + + let first = cleaned.chars().next().expect("cleaned non-empty above"); + if first.is_lowercase() { + let upper: String = first.to_uppercase().collect(); + let mut iter = cleaned.chars(); + iter.next(); + cleaned = upper + iter.as_str(); + } + + let last = cleaned + .chars() + .next_back() + .expect("cleaned non-empty above"); + if last.is_alphanumeric() { + cleaned.push('.'); + } + + let word_count = cleaned.split_whitespace().count(); + Some(AprilPreparedPrompt { + text: cleaned, + // Mirror the bundle's upstream heuristic: three generated frames plus + // two trailing frames for short prompts, one plus two otherwise. + frames_after_eos: if word_count <= 4 { 5 } else { 3 }, + }) +} + +impl AprilPocketTts { + pub(crate) fn load(dir: &Path, num_threads: usize) -> Result { + if num_threads == 0 { + return Err("Pocket TTS num_threads must be at least 1".to_string()); + } + let bundle_path = dir.join(FILE_BUNDLE); + let bundle: Bundle = serde_json::from_slice( + &fs::read(&bundle_path) + .map_err(|err| format!("read {}: {err}", bundle_path.display()))?, + ) + .map_err(|err| format!("parse {}: {err}", bundle_path.display()))?; + + if bundle.schema_version != 2 { + return Err(format!( + "unsupported Pocket TTS bundle schema {} in {}", + bundle.schema_version, + bundle_path.display() + )); + } + if bundle.language != MODEL_LANGUAGE { + return Err(format!( + "expected Pocket TTS language {MODEL_LANGUAGE}, got {}", + bundle.language + )); + } + if bundle.sample_rate != 24_000 + || bundle.frame_rate != 12.5 + || bundle.samples_per_frame != 1_920 + || bundle.latent_dim != 32 + || bundle.conditioning_dim != 1024 + { + return Err(format!( + "unexpected Pocket TTS dimensions: sample_rate={}, frame_rate={}, samples_per_frame={}, latent_dim={}, conditioning_dim={}", + bundle.sample_rate, + bundle.frame_rate, + bundle.samples_per_frame, + bundle.latent_dim, + bundle.conditioning_dim + )); + } + if !bundle.insert_bos_before_voice { + return Err("April Pocket TTS bundle must insert BOS before voice".to_string()); + } + if bundle.pad_with_spaces_for_short_inputs + || bundle.remove_semicolons + || bundle.model_recommended_frames_after_eos.is_some() + || bundle.max_token_per_chunk != 50 + { + return Err("unsupported April Pocket TTS prompt-policy metadata".to_string()); + } + + let tokenizer_path = dir.join(&bundle.tokenizer_file); + let tokenizer = load_tokenizer(&tokenizer_path)?; + let bos_path = dir.join(&bundle.bos_before_voice_file); + let bos_embedding = read_npy_f32(&bos_path)?; + if bos_embedding.len() != bundle.conditioning_dim { + return Err(format!( + "{} has {} values; expected {}", + bos_path.display(), + bos_embedding.len(), + bundle.conditioning_dim + )); + } + + let flow_main = FILE_FLOW_MAIN_INT8; + let flow = FILE_FLOW_INT8; + let mimi_decoder = FILE_MIMI_DECODER_INT8; + + Ok(Self { + // The INT8 layout quantizes only the three generation graphs; + // voice encoding and text conditioning remain full precision. + mimi_encoder: load_session(dir.join(FILE_MIMI_ENCODER), num_threads)?, + text_conditioner: load_session(dir.join(FILE_TEXT_CONDITIONER), num_threads)?, + flow_main: load_session(dir.join(flow_main), num_threads)?, + flow: load_session(dir.join(flow), num_threads)?, + mimi_decoder: load_session(dir.join(mimi_decoder), num_threads)?, + bundle, + tokenizer, + bos_embedding, + cached_voice: None, + }) + } + + pub(crate) fn split_prompt( + &self, + prepared: &AprilPreparedPrompt, + ) -> Result, String> { + if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk { + return Ok(vec![prepared.text.clone()]); + } + + let mut chunks = Vec::new(); + let mut current = String::new(); + for word in prepared.text.split_whitespace() { + let candidate = if current.is_empty() { + word.to_string() + } else { + format!("{current} {word}") + }; + if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk { + current = candidate; + continue; + } + if !current.is_empty() { + chunks.push(std::mem::take(&mut current)); + } + + if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk { + current = word.to_string(); + continue; + } + + let mut fragment = String::new(); + for ch in word.chars() { + let candidate = format!("{fragment}{ch}"); + if !fragment.is_empty() + && self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk + { + chunks.push(std::mem::take(&mut fragment)); + } + fragment.push(ch); + } + current = fragment; + } + if !current.is_empty() { + chunks.push(current); + } + + chunks + .into_iter() + .map(|text| { + let chunk = prepare_april_prompt(&text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + let token_count = self.token_count(&chunk.text)?; + if token_count > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt chunk has {token_count} tokens; maximum is {}", + self.bundle.max_token_per_chunk + )); + } + Ok(chunk.text) + }) + .collect() + } + + pub(crate) fn synth_chunk( + &mut self, + prepared: &AprilPreparedPrompt, + style: &VoiceStyle, + ) -> Result, String> { + let voice_embeddings = self.voice_embeddings(style)?; + let mut flow_state = self.condition_voice(&voice_embeddings)?; + let token_ids = self + .tokenizer + .encode(prepared.text.as_str(), false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .iter() + .copied() + .map(i64::from) + .collect::>(); + if token_ids.is_empty() { + return Ok(Vec::new()); + } + if token_ids.len() > self.bundle.max_token_per_chunk { + return Err(format!( + "Pocket TTS prompt has {} tokens; split_text_into_chunks maximum is {}", + token_ids.len(), + self.bundle.max_token_per_chunk + )); + } + + let token_count = token_ids.len(); + let text_embeddings = self.text_embeddings(token_ids)?; + self.run_flow_main_prefix(&text_embeddings, &mut flow_state)?; + let max_frames = estimate_max_frames(token_count, self.bundle.frame_rate); + let latents = + self.generate_latents(max_frames, prepared.frames_after_eos, &mut flow_state)?; + self.decode_latents(&latents) + } + + fn prepared_token_count(&self, text: &str) -> Result { + let prepared = prepare_april_prompt(text) + .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?; + self.token_count(&prepared.text) + } + + fn token_count(&self, text: &str) -> Result { + Ok(self + .tokenizer + .encode(text, false) + .map_err(|err| format!("tokenize Pocket TTS prompt: {err}"))? + .get_ids() + .len()) + } + + fn voice_embeddings(&mut self, style: &VoiceStyle) -> Result, String> { + let key = ( + style.samples.as_ptr() as usize, + style.samples.len(), + style.sample_rate, + ); + if let Some(cached) = &self.cached_voice { + if (cached.samples_ptr, cached.samples_len, cached.sample_rate) == key { + return Ok(cached.embeddings.clone()); + } + } + + let samples = if style.sample_rate == self.bundle.sample_rate as i32 { + style.samples.clone() + } else { + LinearResampler::create(style.sample_rate, self.bundle.sample_rate as i32) + .ok_or_else(|| { + format!( + "create Pocket TTS resampler {}Hz -> {}Hz", + style.sample_rate, self.bundle.sample_rate + ) + })? + .resample(&style.samples, true) + }; + let audio = Tensor::from_array(( + vec![1_i64, 1, samples.len() as i64], + samples.into_boxed_slice(), + )) + .map_err(ort_error("create voice audio tensor"))?; + let outputs = self + .mimi_encoder + .run(ort::inputs!["audio" => audio]) + .map_err(ort_error("run Mimi encoder"))?; + let (_, encoded) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi encoder output"))?; + if !encoded.len().is_multiple_of(self.bundle.conditioning_dim) { + return Err(format!( + "Mimi encoder returned {} values, not divisible by {}", + encoded.len(), + self.bundle.conditioning_dim + )); + } + let mut embeddings = + Vec::with_capacity(self.bos_embedding.len().saturating_add(encoded.len())); + embeddings.extend_from_slice(&self.bos_embedding); + embeddings.extend_from_slice(encoded); + self.cached_voice = Some(CachedVoice { + samples_ptr: key.0, + samples_len: key.1, + sample_rate: key.2, + embeddings: embeddings.clone(), + }); + Ok(embeddings) + } + + fn condition_voice(&mut self, embeddings: &[f32]) -> Result, String> { + let frames = embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty voice sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create voice embedding tensor"))?; + let mut state = initialize_state(&self.bundle.flow_lm_state_manifest)?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("condition Pocket TTS voice"))?; + replace_state_from_outputs(&mut state, &mut outputs)?; + Ok(state) + } + + fn text_embeddings(&mut self, token_ids: Vec) -> Result, String> { + let tokens = Tensor::from_array(( + vec![1_i64, token_ids.len() as i64], + token_ids.into_boxed_slice(), + )) + .map_err(ort_error("create token tensor"))?; + let outputs = self + .text_conditioner + .run(ort::inputs!["token_ids" => tokens]) + .map_err(ort_error("run text conditioner"))?; + let (_, embeddings) = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract text embeddings"))?; + Ok(embeddings.to_vec()) + } + + fn run_flow_main_prefix( + &mut self, + text_embeddings: &[f32], + state: &mut [StateValue], + ) -> Result<(), String> { + if !text_embeddings + .len() + .is_multiple_of(self.bundle.conditioning_dim) + { + return Err(format!( + "text conditioner returned {} values, not divisible by {}", + text_embeddings.len(), + self.bundle.conditioning_dim + )); + } + let frames = text_embeddings.len() / self.bundle.conditioning_dim; + let sequence = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.latent_dim as i64], + ) + .map_err(ort_error("create empty text sequence"))?; + let text_embeddings = Tensor::from_array(( + vec![1_i64, frames as i64, self.bundle.conditioning_dim as i64], + text_embeddings.to_vec().into_boxed_slice(), + )) + .map_err(ort_error("create text embedding tensor"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("prime Pocket TTS text state"))?; + replace_state_from_outputs(state, &mut outputs) + } + + fn generate_latents( + &mut self, + max_frames: usize, + frames_after_eos: usize, + state: &mut [StateValue], + ) -> Result, String> { + let mut current = vec![f32::NAN; self.bundle.latent_dim]; + let mut latents = Vec::with_capacity(max_frames * self.bundle.latent_dim); + let mut eos_step = None; + let mut rng = rand::rng(); + + for step in 0..max_frames { + let sequence = Tensor::from_array(( + vec![1_i64, 1, self.bundle.latent_dim as i64], + current.clone().into_boxed_slice(), + )) + .map_err(ort_error("create latent input"))?; + let text_embeddings = Tensor::::new( + &ort::memory::Allocator::default(), + [1_i64, 0, self.bundle.conditioning_dim as i64], + ) + .map_err(ort_error("create empty text input"))?; + let mut inputs = vec![ + (Cow::Borrowed("sequence"), SessionInputValue::from(sequence)), + ( + Cow::Borrowed("text_embeddings"), + SessionInputValue::from(text_embeddings), + ), + ]; + append_state_inputs(&mut inputs, state); + let mut outputs = self + .flow_main + .run(inputs) + .map_err(ort_error("run Pocket TTS Flow LM"))?; + let conditioning = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM conditioning"))? + .1 + .to_vec(); + let eos_logit = outputs[1] + .try_extract_tensor::() + .map_err(ort_error("extract Flow LM EOS logit"))? + .1 + .first() + .copied() + .ok_or_else(|| "Flow LM returned empty EOS logit".to_string())?; + replace_state_from_outputs(state, &mut outputs)?; + + if eos_logit > EOS_LOGIT_THRESHOLD && eos_step.is_none() { + eos_step = Some(step); + } + if eos_step.is_some_and(|eos| step >= eos + frames_after_eos) { + break; + } + + let mut noise = + normal_noise(&mut rng, self.bundle.latent_dim, DEFAULT_TEMPERATURE.sqrt()); + let conditioning = Tensor::from_array(( + vec![1_i64, self.bundle.conditioning_dim as i64], + conditioning.into_boxed_slice(), + )) + .map_err(ort_error("create flow conditioning"))?; + let s = Tensor::from_array((vec![1_i64, 1], vec![0.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow start tensor"))?; + let t = Tensor::from_array((vec![1_i64, 1], vec![1.0_f32].into_boxed_slice())) + .map_err(ort_error("create flow end tensor"))?; + let x = Tensor::from_array(( + vec![1_i64, self.bundle.latent_dim as i64], + noise.clone().into_boxed_slice(), + )) + .map_err(ort_error("create flow noise tensor"))?; + let outputs = self + .flow + .run(ort::inputs![ + "c" => conditioning, + "s" => s, + "t" => t, + "x" => x, + ]) + .map_err(ort_error("run Pocket TTS flow"))?; + let flow = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Pocket TTS flow"))? + .1; + if flow.len() != noise.len() { + return Err(format!( + "flow returned {} values; expected {}", + flow.len(), + noise.len() + )); + } + for (sample, delta) in noise.iter_mut().zip(flow) { + *sample += *delta; + } + current.clone_from(&noise); + latents.extend_from_slice(&noise); + } + Ok(latents) + } + + fn decode_latents(&mut self, latents: &[f32]) -> Result, String> { + if latents.is_empty() { + return Ok(Vec::new()); + } + if !latents.len().is_multiple_of(self.bundle.latent_dim) { + return Err(format!( + "latent buffer has {} values, not divisible by {}", + latents.len(), + self.bundle.latent_dim + )); + } + let frame_count = latents.len() / self.bundle.latent_dim; + let mut state = initialize_state(&self.bundle.mimi_state_manifest)?; + let mut audio = Vec::new(); + + for start in (0..frame_count).step_by(DECODER_CHUNK_FRAMES) { + let end = (start + DECODER_CHUNK_FRAMES).min(frame_count); + let values = + latents[start * self.bundle.latent_dim..end * self.bundle.latent_dim].to_vec(); + let latent = Tensor::from_array(( + vec![1_i64, (end - start) as i64, self.bundle.latent_dim as i64], + values.into_boxed_slice(), + )) + .map_err(ort_error("create Mimi latent tensor"))?; + let mut inputs = vec![(Cow::Borrowed("latent"), SessionInputValue::from(latent))]; + append_state_inputs(&mut inputs, &state); + let mut outputs = self + .mimi_decoder + .run(inputs) + .map_err(ort_error("run Mimi decoder"))?; + let samples = outputs[0] + .try_extract_tensor::() + .map_err(ort_error("extract Mimi audio"))? + .1; + audio.extend_from_slice(samples); + replace_state_from_outputs(&mut state, &mut outputs)?; + } + Ok(audio) + } +} + +fn load_session(path: PathBuf, num_threads: usize) -> Result { + if !path.is_file() { + return Err(format!("missing Pocket TTS file: {}", path.display())); + } + Session::builder() + .map_err(ort_error("create ONNX session builder"))? + .with_intra_threads(num_threads) + .map_err(|err| format!("configure ONNX intra-op threads: {err}"))? + .with_inter_threads(1) + .map_err(|err| format!("configure ONNX inter-op threads: {err}"))? + .commit_from_file(&path) + .map_err(|err| format!("load {}: {err}", path.display())) +} + +fn load_tokenizer(path: &Path) -> Result { + let sentencepiece = SentencePieceModel::from_file(path) + .map_err(|err| format!("load {}: {err}", path.display()))?; + let trainer = sentencepiece + .trainer() + .ok_or_else(|| format!("{} has no SentencePiece trainer metadata", path.display()))?; + let normalizer = sentencepiece.normalizer().ok_or_else(|| { + format!( + "{} has no SentencePiece normalizer metadata", + path.display() + ) + })?; + if normalizer.name() != "identity" { + return Err(format!( + "{} uses unsupported SentencePiece normalizer {:?}", + path.display(), + normalizer.name() + )); + } + + let vocab = sentencepiece + .pieces() + .iter() + .map(|piece| (piece.piece().to_owned(), f64::from(piece.score()))) + .collect(); + let mut tokenizer = Tokenizer::new( + Unigram::from( + vocab, + Some(trainer.unk_id() as usize), + trainer.byte_fallback(), + ) + .map_err(|err| format!("construct tokenizer from {}: {err}", path.display()))?, + ); + // SentencePiece's identity normalizer still escapes spaces as U+2581 and + // prepends one marker to the input before unigram segmentation. + tokenizer.with_pre_tokenizer(Some(Metaspace::new('▁', PrependScheme::Always, false))); + Ok(tokenizer) +} + +fn initialize_state(specs: &[StateSpec]) -> Result, String> { + specs + .iter() + .cloned() + .map(|spec| { + let len = shape_len(&spec.shape)?; + let value = match spec.dtype { + StateDtype::Float32 => { + let fill = match spec.fill { + StateFill::Nan => f32::NAN, + StateFill::Empty | StateFill::Zeros => 0.0, + StateFill::Ones => 1.0, + }; + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty float state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create float state tensor"))? + .into_dyn() + } + } + StateDtype::Int64 => { + let fill = i64::from(matches!(spec.fill, StateFill::Ones)); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty integer state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create integer state tensor"))? + .into_dyn() + } + } + StateDtype::Bool => { + let fill = matches!(spec.fill, StateFill::Ones); + if len == 0 { + Tensor::::new(&ort::memory::Allocator::default(), spec.shape.clone()) + .map_err(ort_error("create empty bool state tensor"))? + .into_dyn() + } else { + Tensor::from_array((spec.shape.clone(), vec![fill; len].into_boxed_slice())) + .map_err(ort_error("create bool state tensor"))? + .into_dyn() + } + } + }; + Ok(StateValue { spec, value }) + }) + .collect() +} + +fn append_state_inputs<'a>( + inputs: &mut Vec<(Cow<'a, str>, SessionInputValue<'a>)>, + state: &'a [StateValue], +) { + for value in state { + inputs.push(( + Cow::Borrowed(value.spec.input_name.as_str()), + SessionInputValue::from(&value.value), + )); + } +} + +fn replace_state_from_outputs( + state: &mut [StateValue], + outputs: &mut ort::session::SessionOutputs<'_>, +) -> Result<(), String> { + for value in state { + value.value = outputs + .remove(&value.spec.output_name) + .ok_or_else(|| format!("missing state output {}", value.spec.output_name))?; + } + Ok(()) +} + +fn shape_len(shape: &[i64]) -> Result { + shape.iter().try_fold(1_usize, |len, &dim| { + let dim = usize::try_from(dim).map_err(|_| format!("negative state dimension {dim}"))?; + len.checked_mul(dim) + .ok_or_else(|| format!("state shape overflows usize: {shape:?}")) + }) +} + +fn estimate_max_frames(token_count: usize, frame_rate: f32) -> usize { + ((token_count as f32 / TOKENS_PER_SECOND_ESTIMATE + GENERATION_SECONDS_PADDING) * frame_rate) + .ceil() as usize +} + +fn normal_noise(rng: &mut impl Rng, len: usize, std_dev: f32) -> Vec { + let mut out = Vec::with_capacity(len); + while out.len() < len { + let u1 = rng.random::().max(f32::MIN_POSITIVE); + let u2 = rng.random::(); + let radius = (-2.0_f32 * u1.ln()).sqrt() * std_dev; + out.push(radius * (TAU * u2).cos()); + if out.len() < len { + out.push(radius * (TAU * u2).sin()); + } + } + out +} + +fn read_npy_f32(path: &Path) -> Result, String> { + let bytes = fs::read(path).map_err(|err| format!("read {}: {err}", path.display()))?; + if bytes.len() < 10 || &bytes[..6] != b"\x93NUMPY" { + return Err(format!("{} is not a NumPy array", path.display())); + } + let major = bytes[6]; + let header_len_bytes = match major { + 1 => 2, + 2 | 3 => 4, + _ => { + return Err(format!( + "unsupported NumPy version {major} in {}", + path.display() + )) + } + }; + let header_start = 8 + header_len_bytes; + if bytes.len() < header_start { + return Err(format!("truncated NumPy header in {}", path.display())); + } + let header_len = if header_len_bytes == 2 { + u16::from_le_bytes([bytes[8], bytes[9]]) as usize + } else { + u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize + }; + let data_start = header_start + .checked_add(header_len) + .ok_or_else(|| format!("NumPy header overflow in {}", path.display()))?; + if data_start > bytes.len() { + return Err(format!("truncated NumPy data in {}", path.display())); + } + let header = std::str::from_utf8(&bytes[header_start..data_start]) + .map_err(|err| format!("invalid NumPy header in {}: {err}", path.display()))?; + if !(header.contains("'descr': ' impl FnOnce(ort::Error) -> String { + move |err| format!("{context}: {err}") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shape_len_supports_empty_state_dimensions() { + assert_eq!(shape_len(&[1, 128, 0]).expect("shape"), 0); + assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000); + } + + #[test] + fn normal_noise_has_requested_length() { + let mut rng = rand::rng(); + assert_eq!(normal_noise(&mut rng, 1, 1.0).len(), 1); + assert_eq!(normal_noise(&mut rng, 32, 1.0).len(), 32); + } + + #[test] + fn generation_frame_estimate_scales_with_token_count() { + assert_eq!(estimate_max_frames(3, 12.5), 38); + assert_eq!(estimate_max_frames(300, 12.5), 1_275); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn tokenizer_matches_sentencepiece_reference_including_unknown_words() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let tokenizer = + load_tokenizer(&Path::new(&dir).join("tokenizer.model")).expect("load April tokenizer"); + let cases: &[(&str, &[u32])] = &[ + ("Yep.", &[2462, 263]), + ("Hello there.", &[2994, 310, 263]), + ( + "quizzaciously xyzzy.", + &[ + 260, 1157, 1818, 362, 1814, 323, 260, 568, 327, 1818, 327, 263, + ], + ), + ("I'm listening.", &[268, 264, 283, 260, 604, 273, 263]), + ]; + for (text, expected) in cases { + let encoding = tokenizer.encode(*text, false).expect("tokenize"); + assert_eq!(encoding.get_ids(), *expected, "{text}"); + } + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn loader_splits_oversized_prompts_at_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "This deliberately long sentence repeats ordinary English words so the exact SentencePiece token limit is exercised without relying on punctuation, and it keeps adding more material until the prompt must be divided into multiple independently safe generation chunks before the recurrent state cache can be exhausted."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split prompt"); + + assert!(chunks.len() > 1); + assert!(chunks.iter().all(|chunk| { + engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk + })); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn gary_provost_long_sentence_respects_bundle_token_limit() { + let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to the verified April bundle"); + let engine = AprilPocketTts::load(Path::new(&dir), 1).expect("load April bundle"); + let text = "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important."; + let prepared = prepare_april_prompt(text).expect("prepare prompt"); + let chunks = engine.split_prompt(&prepared).expect("split long sentence"); + let token_counts: Vec<_> = chunks + .iter() + .map(|chunk| engine.token_count(chunk).expect("count tokens")) + .collect(); + + assert_eq!( + chunks, + [ + "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the.", + "Impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.", + ] + ); + assert_eq!(token_counts, [48, 44]); + } +} diff --git a/desktop/src-tauri/src/huddle/pocket_models.rs b/desktop/src-tauri/src/huddle/pocket_models.rs new file mode 100644 index 0000000000..de34c77a70 --- /dev/null +++ b/desktop/src-tauri/src/huddle/pocket_models.rs @@ -0,0 +1,130 @@ +//! Immutable capabilities for Buzz Desktop's April Pocket TTS bundle. + +/// Pinned upstream export repository. +pub const APRIL_MODEL_ID: &str = "KevinAHM/pocket-tts-onnx"; + +/// Pinned revision containing the `english_2026-04` bundle. +pub const APRIL_MODEL_REVISION: &str = "58a6d00cf13d239b6748cb0769f35c580a8f606c"; + +/// Language bundle selected from the pinned export. +pub const APRIL_BUNDLE_ID: &str = "english_2026-04"; + +/// Maximum input size declared by the April bundle. +pub const APRIL_MAX_TOKEN_PER_CHUNK: usize = 50; + +/// One immutable artifact required by the April INT8 runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelArtifact { + pub filename: &'static str, + pub sha256: &'static str, + pub size_bytes: u64, + pub quantized: bool, +} + +/// Capabilities of Buzz Desktop's sole Pocket model. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PocketModelInfo { + pub bundle_id: &'static str, + pub source_model_id: &'static str, + pub revision: &'static str, + pub sample_rate: u32, + pub max_token_per_chunk: usize, + pub artifacts: &'static [PocketModelArtifact], + pub quantized_components: &'static [&'static str], +} + +const INT8_ARTIFACTS: [PocketModelArtifact; 8] = [ + PocketModelArtifact { + filename: "bundle.json", + sha256: "bab643150f437f37df080a710520ff39ed9ebd9a339f8ebdc739f7eddfc28b3f", + size_bytes: 24_381, + quantized: false, + }, + PocketModelArtifact { + filename: "bos_before_voice.npy", + sha256: "f46edf4f7007b7ba4ea58831f49d003e59e167b4641c44bb3addfe9231a780b1", + size_bytes: 4_224, + quantized: false, + }, + PocketModelArtifact { + filename: "tokenizer.model", + sha256: "d461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6", + size_bytes: 59_339, + quantized: false, + }, + PocketModelArtifact { + filename: "flow_lm_main_int8.onnx", + sha256: "f9bd8106b79a0192c1c43399ab938fb24900a95c1c599870d75a884e99000116", + size_bytes: 76_341_079, + quantized: true, + }, + PocketModelArtifact { + filename: "flow_lm_flow_int8.onnx", + sha256: "3dd781ee5abee9e195320bf0106bebd6372a852b3b36352524ee78b40554635d", + size_bytes: 9_962_530, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_decoder_int8.onnx", + sha256: "3630450a3297a101792a6ac66619ebc70ab916b265e6220c2afaef8b1673f925", + size_bytes: 22_684_077, + quantized: true, + }, + PocketModelArtifact { + filename: "mimi_encoder.onnx", + sha256: "853e2ca623b8782d94c3745ec6133bfdff7ce33d9b11128bd29ea03f28d76e3d", + size_bytes: 39_768_446, + quantized: false, + }, + PocketModelArtifact { + filename: "text_conditioner.onnx", + sha256: "4ecee995fb69f85c7a7493d11f7b5ee15d9950facc7ab3f5c9c49ef1e03847bb", + size_bytes: 16_388_344, + quantized: false, + }, +]; + +const INT8_COMPONENTS: [&str; 3] = ["flow_lm_main", "flow_lm_flow", "mimi_decoder"]; + +/// Return immutable metadata for Buzz Desktop's April INT8 model. +pub const fn april_model_info() -> PocketModelInfo { + PocketModelInfo { + bundle_id: APRIL_BUNDLE_ID, + source_model_id: APRIL_MODEL_ID, + revision: APRIL_MODEL_REVISION, + sample_rate: 24_000, + max_token_per_chunk: APRIL_MAX_TOKEN_PER_CHUNK, + artifacts: &INT8_ARTIFACTS, + quantized_components: &INT8_COMPONENTS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn metadata_matches_pinned_int8_layout() { + let info = april_model_info(); + assert_eq!(info.artifacts.len(), 8); + assert_eq!( + info.quantized_components, + ["flow_lm_main", "flow_lm_flow", "mimi_decoder"] + ); + assert_eq!( + info.artifacts + .iter() + .map(|artifact| artifact.size_bytes) + .sum::(), + 165_232_420 + ); + assert!(info + .artifacts + .iter() + .any(|artifact| { artifact.filename == "mimi_encoder.onnx" && !artifact.quantized })); + assert!(!info + .artifacts + .iter() + .any(|artifact| artifact.filename == "mimi_encoder_int8.onnx")); + } +} diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 63a435cd8e..f084cca6d5 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -73,9 +73,8 @@ const SYNTH_STEPS: usize = 1; /// /// Applied only at the *end* of each synthesised sentence to eliminate the /// click that would otherwise occur when a non-zero waveform terminates -/// abruptly. **No fade-in is applied** — see `apply_fade_out` for the -/// rationale and `examples/pocket_onset_probe.rs` for the measurement that -/// motivated removing the leading fade. +/// abruptly. **No fade-in is applied** — see `apply_fade_out` for why preserving +/// the leading waveform is important. const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; /// Length of the zero-sample cushion prepended before each synthesized @@ -101,13 +100,9 @@ const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; /// names chunk stitching as the reliability lever). Our previous /// sentence-per-call path created ~2–4× more seams than upstream. /// -/// We don't ship the SentencePiece tokenizer, so 50 tokens is approximated -/// with a character budget. The bundled 4k-entry vocab averages ~4 chars per -/// token, but usage-weighted English text leans on short common tokens, so -/// the effective ratio is ~2–4 chars/token and 200 chars ≈ 60–100 tokens — -/// modestly above upstream's 50, deliberately: erring large means fewer -/// seams, and even ~100 tokens is far below the model's 500-LM-step (~40 s) -/// ceiling. Do not shrink this budget to chase an exact 50-token match. +/// This character budget performs only coarse sentence packing. The April +/// engine applies its SentencePiece tokenizer afterward and refines every +/// result at the bundle's exact 50-token boundary. const MAX_CHUNK_CHARS: usize = 200; /// Silence inserted between sentences by the TTS pipeline (seconds). @@ -493,18 +488,17 @@ fn tts_worker( // Split into sentences, then group into synthesis chunks: the first // sentence stays alone (fast time-to-first-audio), the rest pack - // greedily up to MAX_CHUNK_CHARS. Each chunk is one `generate()` - // call; playback of chunk N overlaps synthesis of chunk N+1 - // (lookahead pipelining). Grouping matches upstream's ~50-token - // chunking and halves the exposed prosody seams on multi-sentence - // replies — see MAX_CHUNK_CHARS. + // greedily up to MAX_CHUNK_CHARS. Playback of each model unit overlaps + // synthesis of the next one. The Pocket engine applies its exact + // 50-token split; keeping those units within one playback chunk avoids + // adding fades and pauses at token-only boundaries. let sentences: Vec = split_sentences(&text) .into_iter() .filter(|s| !s.trim().is_empty()) .collect(); let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS); - for chunk in &chunks { + 'playback_chunks: for chunk in &chunks { if handle_cancel_or_shutdown( &cancel, &shutdown, @@ -521,51 +515,76 @@ fn tts_worker( continue; } - match engine.synth_chunk(text, "en", &style, SYNTH_STEPS) { - Ok(samples) if !samples.is_empty() => { - let mut audio = clamp_to_full_scale(samples); - // Fade-out only — fading-in would attenuate the consonant - // onset (see `apply_fade_out` docstring + the - // 2026-05-18 "first little sound is missing" regression). - apply_fade_out(&mut audio); - - // Build one contiguous buffer per synthesized sentence: - // lead-in cushion + audio + trailing gap. Keeping this as - // a single rodio source preserves the original queue/drain - // semantics (one append per sentence) while still giving - // every chunk a quiet device warm-up window. - let buf = - build_sentence_append_buffer(&mut first_append, audio, silence_buf_len); - - // Check-and-append under `player_ops`, serialized with - // the monitor: a barge-in may have arrived during - // synthesis (the blocking window the monitor thread - // exists for). Don't append the now-stale sentence — the - // human interrupted; speaking it anyway would talk over - // them. Holding the lock for the check + append means the - // monitor can never clear between our check passing and - // the buffer landing. The flag is deliberately NOT - // consumed here: the loop-top handle_cancel_or_shutdown - // does the full consume (drain queue, reset lead-in) on - // the next iteration. - let _ops = lock_player_ops(&player_ops); - if cancel.load(Ordering::Acquire) { - // Nothing appended; the loop-top consume re-arms - // `first_append` (the flag is still set — the worker - // is its only consumer). + let model_chunks = match engine.split_text_into_chunks(text) { + Ok(model_chunks) => model_chunks, + Err(error) => { + eprintln!("buzz-desktop: TTS chunking failed: {error}"); + break; + } + }; + let model_chunk_count = model_chunks.len(); + for (model_chunk_index, model_chunk) in model_chunks.iter().enumerate() { + if handle_cancel_or_shutdown( + &cancel, + &shutdown, + &tts_active, + &text_rx, + Some((&player, &player_ops)), + ) { + first_append = true; + break 'playback_chunks; + } + + let ends_playback_chunk = model_chunk_index + 1 == model_chunk_count; + match engine.synth_chunk(model_chunk, "en", &style, SYNTH_STEPS) { + Ok(samples) if !samples.is_empty() => { + let mut audio = clamp_to_full_scale(samples); + if ends_playback_chunk { + // Fade only at the playback-chunk boundary. Applying + // it at the model's internal token boundary would + // create an audible dip between contiguous units. + apply_fade_out(&mut audio); + } + + let buf = build_sentence_append_buffer( + &mut first_append, + audio, + silence_buf_len, + model_chunk_index == 0 || player.empty(), + ends_playback_chunk, + ); + + // Check-and-append under `player_ops`, serialized with + // the monitor: a barge-in may have arrived during + // synthesis (the blocking window the monitor thread + // exists for). Don't append the now-stale sentence — the + // human interrupted; speaking it anyway would talk over + // them. Holding the lock for the check + append means the + // monitor can never clear between our check passing and + // the buffer landing. The flag is deliberately NOT + // consumed here: the loop-top handle_cancel_or_shutdown + // does the full consume (drain queue, reset lead-in) on + // the next iteration. + let _ops = lock_player_ops(&player_ops); + if cancel.load(Ordering::Acquire) { + // Nothing appended; the loop-top consume re-arms + // `first_append` (the flag is still set — the worker + // is its only consumer). + break; + } + player.append(SamplesBuffer::new(channels, rate, buf)); + // NOTE: tts_active is set AFTER player.append(), not + // before. Setting it before synthesis would cause STT to + // discard user speech during the synthesis window as + // "echo" even though no audio is actually playing yet. + // See crossfire review C3. + tts_active.store(true, Ordering::Release); + } + Ok(_) => {} + Err(e) => { + eprintln!("buzz-desktop: TTS synth failed: {e}"); break; } - player.append(SamplesBuffer::new(channels, rate, buf)); - // NOTE: tts_active is set AFTER player.append(), not - // before. Setting it before synthesis would cause STT to - // discard user speech during the synthesis window as - // "echo" even though no audio is actually playing yet. - // See crossfire review C3. - tts_active.store(true, Ordering::Release); - } - Ok(_) => {} - Err(e) => { - eprintln!("buzz-desktop: TTS synth failed: {e}"); } } } @@ -646,15 +665,10 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { /// Hard-clamp samples to ±1.0 full scale. /// -/// No gain is applied: Pocket TTS already emits speech-level audio -/// (peaks 0.4–0.97, RMS ≈ −20 dBFS across varied sentences — measured by -/// `examples/pocket_clip_probe`), matching the kyutai reference pipeline, -/// which applies no output scaling. Two earlier gain stages were both -/// regressions against that baseline: per-sentence peak normalization caused -/// level pumping between sentences, and the fixed 9.3× gain that replaced it -/// was calibrated on a single anomalously-quiet bench utterance (peak 0.076) -/// and clipped 13–34% of samples on real speech ("blown out", 2026-06-12). -/// The clamp alone remains as the safety net against outlier transients. +/// No gain is applied because Pocket TTS already emits speech-level audio and +/// the reference pipeline applies no output scaling. Normalizing each sentence +/// would cause level pumping between chunks. The clamp remains only as a safety +/// net against outlier transients. fn clamp_to_full_scale(samples: Vec) -> Vec { samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() } @@ -667,14 +681,10 @@ fn clamp_to_full_scale(samples: Vec) -> Vec { /// /// # Why no fade-in /// -/// An earlier revision (pre 2026-05) symmetrically faded *in* over the same -/// 8 ms window. That swallowed the leading consonant attack on every -/// sentence — Pocket TTS produces real audio energy inside the first -/// millisecond (RMS ≈ 0.02, peak ≈ 0.03 measured across four prompts in -/// `examples/pocket_onset_probe.rs`), and a linear 0→1 ramp over 192 samples -/// scales those onset samples by ≤50 % for the first ~4 ms. The result was -/// the "first little sound or two is missing" regression heard on -/// 2026-05-18. +/// A symmetric fade-in would attenuate the leading consonant attack because +/// Pocket TTS produces real audio energy inside the first millisecond. A +/// linear 0→1 ramp over 192 samples scales those onset samples by ≤50% for the +/// first ~4 ms, which can make the first phoneme sound clipped. /// /// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well /// below the threshold at which a DC-jump would be audible as a click — so @@ -689,13 +699,12 @@ fn apply_fade_out(samples: &mut [f32]) { } } -/// Build the single buffer appended to the rodio `Player` for one synthesised -/// sentence. +/// Build one buffer appended to the rodio `Player` for a synthesis unit. /// -/// Every sentence chunk gets a short lead-in pad immediately before its audio. -/// This matters for chunks that start with soft first phonemes (`I'm`, `I've`): -/// the synthesized buffer can begin with speech within the first millisecond, -/// so the playback layer must provide the device/mixer cushion. +/// Every playback boundary gets a short lead-in pad immediately before its +/// audio. This matters for chunks that start with soft first phonemes (`I'm`, +/// `I've`): the synthesized buffer can begin with speech within the first +/// millisecond, so the playback layer must provide the device/mixer cushion. /// To keep the audible gap unchanged, the trailing silence after this chunk is /// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`): /// sentence N contributes 80 ms of post-speech silence and sentence N+1 @@ -706,6 +715,11 @@ fn apply_fade_out(samples: &mut [f32]) { /// tracked source per synthesized sentence, avoiding source-boundary/drain /// regressions from enqueueing the lead-in, audio, and tail as separate sounds. /// +/// A playback chunk may contain several model-sized synthesis units. Only the +/// first unit receives the onset cushion and only the last receives the +/// remaining gap. If playback underruns while the next unit is synthesized, +/// that unit becomes a new playback boundary and receives a fresh cushion. +/// /// `first_append` is flipped on the first call after the player goes idle. /// The worker uses it in the idle branch of the main loop to distinguish /// "never queued anything since last drain" from "drained after speaking", @@ -714,14 +728,25 @@ fn build_sentence_append_buffer( first_append: &mut bool, audio: Vec, silence_buf_len: usize, + starts_playback_chunk: bool, + ends_playback_chunk: bool, ) -> Vec { if *first_append { *first_append = false; } - let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES); - let mut buf = Vec::with_capacity(SENTENCE_LEAD_IN_SAMPLES + audio.len() + trailing_silence_len); - buf.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES)); + let lead_in_len = if starts_playback_chunk { + SENTENCE_LEAD_IN_SAMPLES + } else { + 0 + }; + let trailing_silence_len = if ends_playback_chunk { + silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) + } else { + 0 + }; + let mut buf = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); + buf.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); buf.extend(audio); buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); buf @@ -734,9 +759,8 @@ fn build_sentence_append_buffer( /// single-sentence cost. Subsequent sentences pack greedily: a sentence /// joins the current chunk while the combined length stays within /// `max_chars`; otherwise it starts a new chunk. A single sentence longer -/// than `max_chars` becomes its own chunk unsplit — Pocket TTS handles long -/// single sentences fine (the ceiling is the 500-LM-step default), it's the -/// *seams* we're minimizing. +/// than `max_chars` becomes its own chunk here, then the Pocket engine splits +/// it at the April bundle's exact token limit before synthesis. /// /// Sentences within a chunk are joined with a single space; sentence-ending /// punctuation is preserved by `split_sentences`, so the model sees natural diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 7887f8bbdb..1908b096b1 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -9,6 +9,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; +#[path = "tts_tests/token_split.rs"] +mod token_split; + // ── Remote interrupt tracker ────────────────────────────────────────────── // // Models the per-peer frame counting logic in the recv task of @@ -785,16 +788,6 @@ fn apply_fade_out_single_sample() { assert_eq!(samples[0], 1.0); } -/// Sanity-check the per-sentence cushion length: 20 ms at 24 kHz must -/// land at exactly 480 samples. This is a const computation, so the -/// real value of this test is documenting *why* 20 ms was chosen — it -/// covers a typical CoreAudio buffer turnover (256–1024 samples) -/// without being audible as user-facing latency. -#[test] -fn sentence_lead_in_is_sane() { - assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); -} - // ── build_sentence_append_buffer tests ─────────────────────────────────── /// REGRESSION: every chunk needs an onset cushion; synthesized chunks @@ -812,6 +805,8 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { &mut first, vec![0.5_f32; SENTENCE_AUDIO_LEN], SILENCE_BUF_LEN, + true, + true, ); assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); @@ -840,11 +835,11 @@ fn lead_in_pad_is_present_for_every_sentence_chunk() { #[test] fn build_sentence_append_buffer_flips_first_append() { let mut first = true; - let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(!first, "first call must flip the flag"); // Subsequent call: still has a per-sentence lead-in, flag stays false. - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert!(!first); } @@ -853,7 +848,7 @@ fn build_sentence_append_buffer_flips_first_append() { #[test] fn first_sentence_leading_silence_is_exactly_lead_in() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } @@ -863,8 +858,10 @@ fn first_sentence_leading_silence_is_exactly_lead_in() { fn sentence_gap_budget_is_preserved() { let mut first = true; let silence_buf_len = 2400; - let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); - let second_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); + let first_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); + let second_buf = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; @@ -877,7 +874,7 @@ fn sentence_gap_budget_is_preserved() { #[test] fn sentence_append_buffer_is_one_contiguous_source() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); assert_eq!(buf.len(), 2400 + 100); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); @@ -949,9 +946,8 @@ fn chunk_grouping_packs_up_to_budget_then_spills() { assert_eq!(chunks[2], d); } -/// A single sentence longer than the budget is passed through unsplit — -/// long single sentences are fine (the LM cap bounds runaway); only seams -/// are being minimized. +/// A single sentence longer than the coarse budget is passed through here; +/// the loaded April engine subsequently enforces its exact 50-token limit. #[test] fn chunk_grouping_oversized_sentence_passes_through() { let long = "word ".repeat(60).trim_end().to_string() + "."; diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs new file mode 100644 index 0000000000..b9249c9afc --- /dev/null +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -0,0 +1,24 @@ +use super::*; + +/// The onset cushion covers 20 ms at the production sample rate. +#[test] +fn sentence_lead_in_is_sane() { + assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); +} + +/// Model-token splits remain contiguous: only the playback chunk as a whole +/// receives its onset cushion and trailing sentence gap. +#[test] +fn token_split_units_do_not_add_sentence_boundary_padding() { + let mut first = true; + let silence_buf_len = 2400; + let first_unit = + build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, false); + let last_unit = + build_sentence_append_buffer(&mut first, vec![0.25; 100], silence_buf_len, false, true); + + assert_eq!(first_unit.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert_eq!(first_unit.last(), Some(&0.5)); + assert_eq!(last_unit.first(), Some(&0.25)); + assert_eq!(first_unit.len() + last_unit.len(), 200 + silence_buf_len); +} From 081f805d5ea25841ab885c7b67a568618a34aa59 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:02:48 -0400 Subject: [PATCH 34/87] feat(agent): optional reply guard reminds a silent turn to publish (#3763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why A Buzz agent's assistant text and reasoning are never shown to anyone — only what it posts through the CLI. A turn that runs fifteen tool calls and never publishes is a silent failure: the requester waits on a result that was produced and thrown away. This adds an optional reminder at the end-of-turn gate, off by default. Tyler asked for it in buzz-mesh; plan iterated to **9.5/10 with @Wren** (Minimalness 9.7, Elegance 9.5, Correctness 9.3). ## What `BUZZ_AGENT_REQUIRE_REPLY=1` (default off, per-agent opt-in). A turn about to end with no recognized attempt to post gets a reminder and is rerolled. **At most two, then the turn ends regardless** — the guard catches accidental omission, it does not compel speech. The reminder text explicitly licenses silence so it cannot fight the base prompt's "silence is usually correct." **This is not a new MCP hook.** `RunCtx::run` *is* the turn, so the two per-turn locals need no plumbing, and every tool call already passes through it with arguments visible. The objection is appended at the existing `_Stop` gate and rides `push_hook_outputs_as_tool_results`, so the model receives it as a lower-trust tool result with `{hook, server, text}` attribution. No new trust path, no new lifecycle event, no dev-mcp or CLI protocol change. Earlier revisions of this plan needed four crates (a `_UserPromptSubmit` hook, a marker file, a `buzz-cli` change, dev-mcp state). Tyler pointed out the agent already knows both facts; that deleted all of it. Net runtime change is ~35 lines in `agent.rs` + ~4 in `config.rs`. ### Recognition contract A registered non-hook tool whose qualified name ends in `__shell`, whose `command` argument contains `messages send` or `reactions add`. - **The `__` separator is exact, not approximate.** Given `has()` + `!is_hook()`, `ends_with("__shell")` is *provably equivalent* to a bare name of `shell`: registration forbids `__` in server and bare names (`mcp.rs:227,268`) and qnames are `{server}__{bare}`, so a trailing `__shell` could only straddle the separator if the bare name began with `_` — which `is_hook` excludes. Without the separator, `powershell` and `noshell` would match. - **Reads the structured `command` field**, not serialized arguments, so a `description` that quotes a send cannot disarm the guard, and a non-string `command` is rejected rather than coerced. - **Detects an attempt, not a successful publish.** A failed send already returns non-zero exit and error JSON — louder than this reminder. The variable is named `buzz_reply_call_seen` so the code can't pretend otherwise. - **Checked after the per-turn tool-call cap**, since a discarded call never ran. - `messages send` also covers `messages send-diff`. Reactions count because the base prompt directs agents to react rather than post a bare acknowledgement. **Known limits, both deliberate and documented:** a command assembled at runtime (`$CMD`) or hidden in a wrapper script is missed; text that merely quotes a send (`echo "buzz messages send"`) matches. Missing a real post is the expensive direction and substring matching is the forgiving one there. Neither edge is pinned by a test, so the matcher stays free to improve. ### Budget Reminders share `BUZZ_AGENT_STOP_MAX_REJECTIONS`, the existing outer cap on every end-turn objection. Default 3 fits both; at 1 only one fits; at 0 the guard is off with the hooks. A round carrying both a hook objection and a reminder costs one rejection and delivers both texts. An independent budget would either violate that bound or need a second arbitration rule. ## Prior art - **#3467** (closed) built the same detector one layer up in `buzz-acp` for a different remedy. None of its symbols are on main — this borrows its permission to be coarse, but reads structured data that ACP didn't have. - **#3648** (open) detects turns with *no output at all*; a turn with fifteen tool calls and no post counts as output there, so it does not cover this case. - **#3741** (merged) is mesh-only. ## Testing **14 new tests.** 4 unit tests on the matcher; 10 integration tests through the ACP wire harness: off by default, `=0` still off, opted-in silent → exactly 2 reminders then `end_turn`, registered `fake__shell` send → 0 reminders, hallucinated `fake__shell` → still reminded, publish call truncated past the 64-call cap → still reminded, budget 1 → 1 reminder, budget 0 → off, combined `_Stop` hook objection + reminder → one round both texts and after 2 reminders the hook objection continues alone, unparseable `=true` → startup error naming the key. **10 mutation checks, each breaking a specific named test** — neutralize the nag cap, stop sharing the budget, neutralize `buzz_reply_call_seen`, drop `has`/`is_hook`, ignore the flag, drop the `__`, drop `reactions add`, read serialized args, move detection before truncation. `tests/bin/fake_mcp.rs` gains `FAKE_MCP_SHELL_TOOL=1`: it previously exposed no tool with a bare name of `shell`, so the satisfied-guard path was untestable. Full `cargo test -p buzz-agent` green at 9e0ae1f04; clippy `-D warnings` and `cargo fmt --check` clean. **Unrelated flake found:** `cancelled_turn_with_usage_emits_notification_before_response` (`tests/fake_llm.rs`) is timing-sensitive. Under 10 loaded cores it fails **2/20 on this branch and 1/20 at unmodified `origin/main@02be413b8`** — pre-existing, not caused by this change (which is inert without the env var). Flagging so it isn't misattributed to the next PR that's open when CI hits it. ## Docs `crates/buzz-agent/README.md` is the primary home (env var, recognition contract, limits, budget interaction). `docs/MCP_DRIVEN_HOOKS.md` gets a short cross-reference explaining this is *not* a hook — otherwise readers hunt for a `_ReplyGuard` tool that doesn't exist. --------- Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Co-authored-by: Dawn (sprout agent) Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- crates/buzz-agent/README.md | 61 +++ crates/buzz-agent/src/agent.rs | 182 ++++++- crates/buzz-agent/src/config.rs | 12 + crates/buzz-agent/src/llm.rs | 1 + crates/buzz-agent/tests/bin/fake_mcp.rs | 26 +- crates/buzz-agent/tests/regressions.rs | 462 ++++++++++++++++++ .../src/managed_agents/relay_mesh.rs | 89 +++- docs/MCP_DRIVEN_HOOKS.md | 17 + 8 files changed, 847 insertions(+), 3 deletions(-) diff --git a/crates/buzz-agent/README.md b/crates/buzz-agent/README.md index f138e4a4f1..5d942777d5 100644 --- a/crates/buzz-agent/README.md +++ b/crates/buzz-agent/README.md @@ -163,6 +163,67 @@ Everything is environment variables. No flags, no config files. (We are a subpro | `BUZZ_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | | `BUZZ_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | | `BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES` | `51200` | 50 KiB. Per-result cap on tool-output text; oversize is middle-elided (head + tail kept) with an inline marker. Images are exempt. | +| `BUZZ_AGENT_REQUIRE_REPLY` | `0` (`1` on mesh) | `1` enables the [reply guard](#reply-guard) — remind the model to publish when a turn is about to end with nothing posted to Buzz. Desktop defaults it to `1` for Buzz shared-compute agents. | + + +## Reply Guard + +Off by default, except on Buzz shared-compute (mesh) agents, where Buzz Desktop +sets `BUZZ_AGENT_REQUIRE_REPLY=1` automatically. With it enabled, a turn that is +about to end without any recognized attempt to post to Buzz gets a reminder that +its assistant text is invisible to humans, and is rerolled. + +This exists because a Buzz agent's reasoning and tool output are not shown to +anyone. A turn that does real work and never posts is a silent failure — the +requester waits on a result that was produced and thrown away. + +Mesh agents get it by default because they run on small local models, which are +the ones most likely to do the work and then end the turn without publishing it. +Setting `BUZZ_AGENT_REQUIRE_REPLY=0` on the agent, persona, or global env opts a +mesh agent back out; the default never overrides an explicit value. + +**Advisory, never a trap.** At most two reminders, then the turn ends whether or +not anything was published. The guard catches accidental omission; it does not +compel speech. The reminder text explicitly licenses silence, because the +built-in system prompt says publishing is optional and silence is often the +correct outcome. + +**Recognition contract.** A turn counts as having replied when it issues a call +that: + +- resolves to a registered, non-hook tool (a hallucinated tool name is rejected + at preflight and never runs, so it must not disarm the guard), +- whose qualified name ends in `__shell` — i.e. the bare tool name is exactly + `shell`, which is `buzz-dev-mcp`'s shell tool and any other server's, and +- whose `command` argument contains `messages send` or `reactions add`. + +`messages send` also covers `messages send-diff`. Reactions count because the +built-in prompt directs agents to react rather than post a bare +acknowledgement, so nagging an agent that reacted would punish documented +behavior. + +Detection is checked **after** the per-turn tool-call cap +(`MAX_TOOL_CALLS_PER_TURN`) is applied: a publish-shaped call that was discarded +never ran. + +**It recognizes an attempt, not a successful publish.** Only the command text is +inspected, never the exit status. A send that fails still satisfies the guard — +which is fine, since a failed send already returns a non-zero exit and error +JSON to the model, louder feedback than a reminder. + +**Known limits**, both deliberate. A command assembled at runtime (`$CMD`) or +buried in a wrapper script is missed, so that turn is reminded despite having +posted. Text that merely quotes a send (`echo "buzz messages send"`) matches, so +that turn is not reminded. Missing a real post is the expensive direction, and +substring matching is the forgiving one there. Neither edge is pinned by a test; +the matcher is free to improve. + +**Budget.** Reminders ride the existing `_Stop` gate and share +`BUZZ_AGENT_STOP_MAX_REJECTIONS` — the outer cap on every end-turn objection. +At the default 3 both reminders fit; at 1 only one does; at 0 the guard is off +along with the hooks. A round carrying both a `_Stop` hook objection and a +reminder costs one rejection and delivers both texts. This is not a new +lifecycle hook — see [MCP_DRIVEN_HOOKS.md](../../docs/MCP_DRIVEN_HOOKS.md). ## Providers diff --git a/crates/buzz-agent/src/agent.rs b/crates/buzz-agent/src/agent.rs index 48d4ea3b02..8e14fee195 100644 --- a/crates/buzz-agent/src/agent.rs +++ b/crates/buzz-agent/src/agent.rs @@ -21,6 +21,80 @@ use crate::wire::{self, WireSender}; const ERROR_REFLECTION_SUFFIX: &str = "\n\n[Reflect] Before retrying, identify the cause and change your approach."; +/// 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 +/// published: the guard exists to catch accidental omission, not to compel +/// speech. The shared `stop_max_rejections` budget can cut this lower — see +/// [`Config::require_reply`](crate::config::Config::require_reply). +const MAX_REPLY_NAGS: u32 = 2; + +/// Server label on the synthetic reply-guard objection. +/// +/// Not a real MCP server. It rides the same tool-result path as `_Stop` hook +/// output, so the model sees `{hook, server, text}` attribution naming the +/// in-process guard rather than an MCP server that could be impersonated. +const REPLY_GUARD_SERVER: &str = "buzz-agent"; + +/// Reminder text emitted when a turn is about to end with nothing published. +/// +/// Explicitly licenses silence. The base prompt tells agents that publishing is +/// optional and "silence is usually correct"; a reminder that argued otherwise +/// would fight that instruction and make agents chattier. +const REPLY_GUARD_NAG: &str = "You are about to end this turn without calling `buzz messages send`. \ +Your assistant text and reasoning are never shown to anyone — if you did work, found an answer, \ +or hit a blocker that someone is waiting on, it exists only if you publish it. \ +If you already posted, or if silence is genuinely correct for this turn, ignore this and end your turn."; + +/// Whether `call` is a recognized attempt to publish a reply to Buzz. +/// +/// Recognizes an *attempt*, not a successful publish: the command text is +/// inspected, never the exit status. That is deliberate — a send that fails +/// already returns a non-zero exit and error JSON to the model, which is louder +/// feedback than the reminder this gates. +/// +/// `has` + `!is_hook` are the same checks the dispatcher uses to accept a call +/// (see `execute_calls`), so a hallucinated `fake__shell` — rejected at preflight +/// and never executed — cannot disarm the guard. They must stay *before* +/// [`is_reply_shaped`]: together with them, and only with them, the `__shell` +/// suffix is exactly equivalent to "the bare tool name is `shell`". +fn is_buzz_reply_call(call: &ToolCall, mcp: &McpRegistry) -> bool { + mcp.has(&call.name) && !mcp.is_hook(&call.name) && is_reply_shaped(&call.name, &call.arguments) +} + +/// Whether a tool name and arguments have the shape of a Buzz publish command. +/// +/// Split from [`is_buzz_reply_call`] only so the matcher is testable without a +/// live [`McpRegistry`]; callers must apply the registry checks first. +/// +/// On the name: `ends_with("__shell")` is exact rather than approximate *given* +/// those checks. Registration rejects `__` in both server names and bare tool +/// names, and qualified names are `{server}__{bare}`, so a trailing `__shell` can +/// only straddle the separator if the bare name starts with `_` — which `is_hook` +/// already excludes. Dropping the separator would not be exact: `powershell` and +/// `noshell` both end in `shell`. +/// +/// On the command: a deliberately coarse substring test, scoped to the structured +/// `command` field so unrelated metadata — a `description` that quotes a send — +/// cannot suppress the guard, and a non-string `command` is rejected rather than +/// coerced. Known limits, both accepted: a command assembled at runtime (`$CMD`) +/// or hidden in a wrapper script is missed, and text that merely quotes a send +/// (`echo "buzz messages send"`) matches. Missing a real post is the expensive +/// direction, and substring matching is the more forgiving one there. +fn is_reply_shaped(name: &str, arguments: &serde_json::Value) -> bool { + name.ends_with("__shell") + && arguments + .get("command") + .and_then(|v| v.as_str()) + .is_some_and(|cmd| { + // `messages send` also covers `messages send-diff`. `reactions + // add` counts because the base prompt directs agents to react + // rather than post a bare acknowledgement, so nagging an agent + // that reacted would punish documented-correct behavior. + cmd.contains("messages send") || cmd.contains("reactions add") + }) +} + pub struct RunCtx<'a> { pub cfg: &'a Config, /// Effective model for this session. Usually equals `cfg.model`; overridden @@ -102,6 +176,14 @@ impl RunCtx<'_> { // session) so a stubborn exchange can't permanently disable the stop // guard for a long-lived session; `max_rounds` still caps the loop. let mut stop_rejections = 0u32; + // Reply-guard state for this prompt. `prompt()` *is* the turn, so + // locals here are per-turn by construction — same shape as + // `stop_rejections` above. + // + // Named for what it proves: a *recognized attempt* to publish, not a + // successful publish. See `is_buzz_reply_call`. + let mut buzz_reply_call_seen = false; + let mut reply_nags = 0u32; loop { if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { return Ok(StopReason::MaxTurnRequests); @@ -264,7 +346,7 @@ impl RunCtx<'_> { if stop_rejections >= self.cfg.stop_max_rejections { return Ok(stop); } - let objections = self + let mut objections = self .mcp .call_hooks( "_Stop", @@ -273,6 +355,17 @@ impl RunCtx<'_> { &self.cfg.hook_servers, ) .await; + // Reply guard shares this gate and this budget, so a round + // carrying both a hook objection and a reply reminder costs + // one rejection and delivers both texts. + if self.cfg.require_reply + && !buzz_reply_call_seen + && reply_nags < MAX_REPLY_NAGS + { + reply_nags += 1; + objections + .push((REPLY_GUARD_SERVER.to_string(), REPLY_GUARD_NAG.to_string())); + } if !objections.is_empty() { stop_rejections = stop_rejections.saturating_add(1); push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); @@ -290,6 +383,11 @@ impl RunCtx<'_> { ); calls.truncate(MAX_TOOL_CALLS_PER_TURN); } + // Deliberately after truncation: a publish-shaped call that was + // discarded never runs, so it must not suppress the reminder. + if self.cfg.require_reply && !buzz_reply_call_seen { + buzz_reply_call_seen = calls.iter().any(|c| is_buzz_reply_call(c, self.mcp)); + } self.history.push(HistoryItem::Assistant { text: response.text, tool_calls: calls.clone(), @@ -799,6 +897,88 @@ mod tests { use super::*; use serde_json::json; + /// 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] + fn reply_shape_matches_documented_send_forms() { + for cmd in [ + "buzz messages send --channel X --content Y", + "buzz --relay wss://r messages send --channel X --content Y", + "/abs/path/buzz messages send", + "printf 'hi' | buzz messages send --content -", + "buzz messages send-diff --diff -", + "buzz reactions add --event E --emoji +", + // Assembled through another shell: rev 3's tokenizer missed this. + r#"sh -c "buzz messages send --channel X""#, + ] { + assert!( + is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} to count as a publish attempt" + ); + } + } + + /// Commands that do real work but do not reply in the originating + /// conversation must still be nagged. + #[test] + fn reply_shape_rejects_non_reply_commands() { + for cmd in [ + "buzz messages get --channel X", + "buzz channels list", + "buzz reactions remove --event E", + "buzz pr open --title T", + "buzz social publish --content hi", + "buzz notes set --name n", + "cargo test -p buzz-agent", + ] { + assert!( + !is_reply_shaped("dev__shell", &json!({ "command": cmd })), + "expected {cmd:?} not to count as a publish attempt" + ); + } + } + + /// The `__` separator is load-bearing: `ends_with("shell")` alone would + /// accept any registered tool whose name merely ends in those letters, and + /// `has()` proves registration, not the bare name. + #[test] + fn reply_shape_requires_the_qname_separator() { + let args = json!({ "command": "buzz messages send --channel X" }); + for name in [ + "dev__powershell", + "dev__noshell", + "shell", + "dev__send_message", + ] { + assert!( + !is_reply_shaped(name, &args), + "{name} must not satisfy the shell-tool check" + ); + } + assert!(is_reply_shaped("dev__shell", &args)); + assert!(is_reply_shaped("buzz-dev-mcp__shell", &args)); + } + + /// Only the field that carries the executable command counts. Searching + /// serialized arguments instead would let arbitrary metadata disarm the + /// guard, turning a description into an attempted send. + #[test] + fn reply_shape_reads_only_the_command_field() { + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "description": "buzz messages send --channel X" }) + )); + assert!(!is_reply_shaped( + "dev__shell", + &json!({ "workdir": "buzz messages send" }) + )); + // Malformed `command` is rejected, not coerced — and must not panic. + assert!(!is_reply_shaped("dev__shell", &json!({ "command": 42 }))); + assert!(!is_reply_shaped("dev__shell", &json!({ "command": null }))); + assert!(!is_reply_shaped("dev__shell", &json!({}))); + assert!(!is_reply_shaped("dev__shell", &json!("not an object"))); + } + /// A9 regression: `reasoning_details` contributes real bytes to /// `estimated_bytes` (see `types.rs::HistoryItem::size_with`), so a /// history item carrying a large opaque reasoning array must actually diff --git a/crates/buzz-agent/src/config.rs b/crates/buzz-agent/src/config.rs index a0e64f1a9d..afbda5379d 100644 --- a/crates/buzz-agent/src/config.rs +++ b/crates/buzz-agent/src/config.rs @@ -720,6 +720,16 @@ pub struct Config { /// Maximum `_Stop` rejections per prompt. Default 3. Set to 0 to /// disable `_Stop` hooks entirely (agent always honors end_turn). pub stop_max_rejections: u32, + /// Remind the model to publish when a turn is about to end without any + /// recognized attempt to post to Buzz. Default off; opt in per agent with + /// `BUZZ_AGENT_REQUIRE_REPLY=1`. + /// + /// Advisory only: at most `MAX_REPLY_NAGS` reminders (see `agent.rs`), + /// then the turn ends regardless. Bounded by the same + /// `stop_max_rejections` budget as `_Stop` hooks, which is the outer cap on + /// all end-turn objections — at the default 3 both reminders fit; at 1 only + /// one does; at 0 the guard is off with the hooks. + pub require_reply: bool, /// Hook server allowlist. See [`HookServers`] for variant semantics. /// Default (env unset/empty) is `None` — hooks are off unless the /// operator explicitly opts in. @@ -851,6 +861,7 @@ impl Config { max_parallel_tools: parse_env("BUZZ_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, hook_timeout: Duration::from_millis(parse_env("BUZZ_AGENT_HOOK_TIMEOUT_MS", 2500u64)?), stop_max_rejections: parse_env("BUZZ_AGENT_STOP_MAX_REJECTIONS", 3u32)?, + require_reply: parse_env("BUZZ_AGENT_REQUIRE_REPLY", 0u8)? != 0, hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), hints_enabled: parse_env("BUZZ_AGENT_NO_HINTS", 0u8)? == 0, thinking_effort: parse_thinking_effort(env("BUZZ_AGENT_THINKING_EFFORT").as_deref())?, @@ -893,6 +904,7 @@ impl Config { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, hints_enabled: false, thinking_effort: None, diff --git a/crates/buzz-agent/src/llm.rs b/crates/buzz-agent/src/llm.rs index f595a165e5..73c7e1faf2 100644 --- a/crates/buzz-agent/src/llm.rs +++ b/crates/buzz-agent/src/llm.rs @@ -2355,6 +2355,7 @@ mod tests { max_parallel_tools: 1, hook_timeout: Duration::from_secs(1), stop_max_rejections: 0, + require_reply: false, hook_servers: HookServers::None, api_key: "key".into(), model: "model".into(), diff --git a/crates/buzz-agent/tests/bin/fake_mcp.rs b/crates/buzz-agent/tests/bin/fake_mcp.rs index 0bbd1d3478..5b660da48c 100644 --- a/crates/buzz-agent/tests/bin/fake_mcp.rs +++ b/crates/buzz-agent/tests/bin/fake_mcp.rs @@ -33,6 +33,11 @@ //! — expose a `_PostCompact` hook tool //! FAKE_MCP_POSTCOMPACT_TEXT=text //! — `_PostCompact` returns this (default: "") +//! FAKE_MCP_SHELL_TOOL=1 — expose a tool whose bare name is `shell` +//! (registered as `__shell`), taking a +//! `command` string. Lets a test drive the +//! reply guard's recognition of a real, +//! registered shell tool. use std::io::{BufRead, Write}; @@ -76,6 +81,7 @@ fn make_tools( desc: &str, include_stop_hook: bool, include_post_compact_hook: bool, + include_shell_tool: bool, ) -> Vec { let mut tools: Vec = (0..count) .map(|i| { @@ -100,6 +106,17 @@ fn make_tools( "inputSchema": { "type": "object", "properties": {} }, })); } + if include_shell_tool { + tools.push(json!({ + "name": "shell", + "description": "run a shell command", + "inputSchema": { + "type": "object", + "properties": { "command": { "type": "string" } }, + "required": ["command"], + }, + })); + } tools } @@ -136,6 +153,7 @@ fn main() { let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); let mut stop_calls_seen: usize = 0; let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let shell_tool = env_flag("FAKE_MCP_SHELL_TOOL"); let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); // Use a channel-based stdin reader so notifications (which carry no id) @@ -206,7 +224,13 @@ fn main() { write_response( id, json!({ - "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + "tools": make_tools( + tool_count, + &desc, + stop_hook, + post_compact_hook, + shell_tool, + ) }), ); } diff --git a/crates/buzz-agent/tests/regressions.rs b/crates/buzz-agent/tests/regressions.rs index 2e0b579c84..abb4f7b311 100644 --- a/crates/buzz-agent/tests/regressions.rs +++ b/crates/buzz-agent/tests/regressions.rs @@ -1819,3 +1819,465 @@ async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { let _ = std::fs::remove_file(&call_received_marker); h.shutdown().await; } + +// --------------------------------------------------------------------------- +// Reply guard (`BUZZ_AGENT_REQUIRE_REPLY`) +// +// The guard reminds the model to publish when a turn is about to end without +// any recognized attempt to post to Buzz. It rides the existing `_Stop` gate +// and shares its rejection budget, so most of these tests count LLM calls: +// each reminder costs exactly one extra round. +// --------------------------------------------------------------------------- + +/// Number of reply-guard reminders present in one captured LLM request. +/// +/// A reminder is a tool-role message whose JSON body is attributed to the +/// in-process guard (`server: "buzz-agent"`) at the `_Stop` hook point — the +/// same lower-trust shape as real hook output. +fn reply_nag_count(request: &Value) -> usize { + request["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["role"] == "tool" + && serde_json::from_str::(m["content"].as_str().unwrap_or("")) + .map(|p| p["hook"] == "_Stop" && p["server"] == "buzz-agent") + .unwrap_or(false) + }) + .count() + }) + .unwrap_or(0) +} + +/// A publish-shaped call to a real registered shell tool. +fn openai_shell_send(id: &str) -> Value { + openai_tool_call( + id, + "fake__shell", + json!({ "command": "buzz messages send --channel c --content hi" }), + ) +} + +/// Run one prompt to completion, answering any permission requests, and +/// return the final response. +async fn prompt_to_completion(h: &mut Harness, sid: &str) -> Value { + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .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!(p) { + return v; + } + } +} + +/// Default off: a silent turn ends on the first end_turn with no extra round. +/// This is the invariant that keeps the feature free for everyone who hasn't +/// opted in. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_by_default() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "guard must be inert when unset, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// `BUZZ_AGENT_REQUIRE_REPLY=0` is off too — the toggle is numeric, so a +/// literal `0` must not read as "set, therefore on". +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_explicit_zero_is_off() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "0")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "REQUIRE_REPLY=0 must behave as off, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// Opted in and silent: exactly two reminders, then the turn is allowed to +/// end. The guard is advisory — it must never trap a turn. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_nags_twice_then_lets_the_turn_end() { + // Budget defaults to 3, so the cap that stops the loop here is + // MAX_REPLY_NAGS = 2, not the rejection budget. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected 2 reminders then end_turn (3 LLM calls), got {}", + captured.len() + ); + assert_eq!( + reply_nag_count(&captured[0]), + 0, + "reminder before any end_turn" + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(reply_nag_count(&captured[2]), 2); + + // The reminder must name the command it wants and license silence, so it + // cannot fight the base prompt's "silence is usually correct". + let msgs = captured[2]["messages"].as_array().unwrap(); + let nag = msgs + .iter() + .filter_map(|m| serde_json::from_str::(m["content"].as_str().unwrap_or("")).ok()) + .find(|p| p["server"] == "buzz-agent") + .expect("reminder body"); + let text = nag["text"].as_str().unwrap_or(""); + assert!( + text.contains("buzz messages send"), + "reminder should name the command: {text}" + ); + assert!( + text.contains("silence is genuinely correct"), + "reminder must license silence: {text}" + ); + h.shutdown().await; +} + +/// A real publish attempt through a registered shell tool satisfies the guard: +/// no reminder, no extra round. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_satisfied_by_registered_shell_send() { + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("posted"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env(&llm.url, &[("BUZZ_AGENT_REQUIRE_REPLY", "1")]).await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "a recognized send must not be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 0); + h.shutdown().await; +} + +/// A publish-shaped call to a shell tool that is *not registered* never runs — +/// preflight rejects it — so it must not disarm the guard. This is what the +/// `has`/`is_hook` checks in the predicate buy. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_unregistered_shell_tool() { + // FAKE_MCP_SHELL_TOOL is absent, so `fake__shell` is a hallucination. + let llm = spawn_capturing_llm(vec![ + openai_shell_send("tc1"), + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp(&mut h, &[("FAKE_MCP_TOOL_COUNT", "1")]).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected the hallucinated call to still be nagged, got {} LLM calls", + captured.len() + ); + let msgs = captured[1]["messages"].as_array().unwrap(); + assert!( + msgs.iter() + .any(|m| m["role"] == "tool" + && m["content"].as_str().unwrap_or("").contains("unknown tool")), + "expected preflight to reject the call: {msgs:?}" + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// A publish-shaped call discarded by the per-turn tool-call cap never runs, +/// so it must not suppress the reminder either. Pins the check's placement +/// after `calls.truncate(MAX_TOOL_CALLS_PER_TURN)`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_ignores_calls_lost_to_the_turn_cap() { + // 64 filler calls (the cap) followed by the publish attempt, which is + // therefore truncated away. The shell tool *is* registered here, so only + // the placement — not tool identity — can explain the reminder. + let mut calls: Vec = (0..64) + .map(|i| { + json!({ + "id": format!("c{i}"), + "type": "function", + "function": { "name": "fake__tool_0", "arguments": "{}" }, + }) + }) + .collect(); + calls.push(json!({ + "id": "c-send", + "type": "function", + "function": { + "name": "fake__shell", + "arguments": json!({ "command": "buzz messages send --channel c --content hi" }) + .to_string(), + }, + })); + let truncated_send = json!({ + "id": "cc-trunc", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": calls }, + "finish_reason": "tool_calls", + }], + }); + let llm = spawn_capturing_llm(vec![ + truncated_send, + openai_text("silent-1"), + openai_text("silent-2"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[("FAKE_MCP_TOOL_COUNT", "1"), ("FAKE_MCP_SHELL_TOOL", "1")], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "a truncated send must still be nagged, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[2]), 1); + h.shutdown().await; +} + +/// The shared `_Stop` rejection budget is the outer cap: at 1 the guard gets +/// one reminder instead of two. Documented degradation, not a bug. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_bounded_by_stop_rejection_budget() { + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("must-not-be-requested"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "budget 1 must allow exactly one reminder, got {} LLM calls", + captured.len() + ); + assert_eq!(reply_nag_count(&captured[1]), 1); + h.shutdown().await; +} + +/// Budget 0 disables every objection at the gate, including this one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_off_when_stop_budget_is_zero() { + let llm = spawn_capturing_llm(vec![openai_text("done"), openai_text("unexpected")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "0"), + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "budget 0 must disable the guard, got {} LLM calls", + captured.len() + ); + h.shutdown().await; +} + +/// The two axes are independent inside one shared budget: a round carrying +/// both a `_Stop` hook objection and a reminder costs one rejection and +/// delivers both texts, and once the reminders are spent the hook objection +/// continues alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn reply_guard_combines_with_stop_hook_objection() { + // The hook objects on its first 3 calls, then clears. Reminders stop + // after 2, so round 3 must carry the hook text and no new reminder. + let llm = spawn_capturing_llm(vec![ + openai_text("silent-1"), + openai_text("silent-2"), + openai_text("silent-3"), + openai_text("silent-4"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("BUZZ_AGENT_REQUIRE_REPLY", "1"), + ("MCP_HOOK_SERVERS", "fake"), + ("BUZZ_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open todos"), + ("FAKE_MCP_STOP_COUNT", "3"), + ], + ) + .await; + + let r = prompt_to_completion(&mut h, &sid).await; + assert_eq!(r["result"]["stopReason"], "end_turn"); + + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 4, + "expected 3 objecting rounds then a clear end, got {}", + captured.len() + ); + + let hook_objections = |req: &Value| -> usize { + req["messages"] + .as_array() + .map(|msgs| { + msgs.iter() + .filter(|m| { + m["content"] + .as_str() + .unwrap_or("") + .contains("you have open todos") + }) + .count() + }) + .unwrap_or(0) + }; + + // Round 2 carries one of each — a single rejection bought both texts. + assert_eq!(reply_nag_count(&captured[1]), 1); + assert_eq!(hook_objections(&captured[1]), 1); + // Round 4: the hook objected three times, the guard only twice. + assert_eq!(reply_nag_count(&captured[3]), 2); + assert_eq!(hook_objections(&captured[3]), 3); + h.shutdown().await; +} + +/// An unparseable toggle is a startup error, not a silent default. `parse_env` +/// is generic over `FromStr`, so this also pins the numeric type: a `bool` +/// field would have rejected the documented `1`. +#[test] +fn reply_guard_rejects_unparseable_toggle() { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_buzz-agent")) + .env("BUZZ_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("BUZZ_AGENT_REQUIRE_REPLY", "true") + .stdin(Stdio::null()) + .output() + .expect("run buzz-agent"); + assert!( + !out.status.success(), + "expected a config error exit, got {:?}", + out.status + ); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("BUZZ_AGENT_REQUIRE_REPLY"), + "expected the offending key in the error, got: {stderr}" + ); +} diff --git a/desktop/src-tauri/src/managed_agents/relay_mesh.rs b/desktop/src-tauri/src/managed_agents/relay_mesh.rs index 327c106bc8..5c246feedc 100644 --- a/desktop/src-tauri/src/managed_agents/relay_mesh.rs +++ b/desktop/src-tauri/src/managed_agents/relay_mesh.rs @@ -47,6 +47,13 @@ pub fn apply_relay_mesh_env( // may deliberately choose a smaller cap or a different effort. This function // runs after those layers during readiness, so never clobber their values. insert_default_if_unset(env, "BUZZ_AGENT_MAX_OUTPUT_TOKENS", "4096"); + // Mesh agents run on small local models, which are the ones most likely to + // do the work and then end the turn without publishing it — the failure the + // reply guard exists to catch. Everywhere else it stays opt-in and unset. + // A default, not policy: an explicit `0` from the agent/persona/global env + // survives (see `insert_default_if_unset`, and the copy-forward list in + // `relay_mesh_process_env` that preserves it through the spawn path). + insert_default_if_unset(env, "BUZZ_AGENT_REQUIRE_REPLY", "1"); // Deliberately no BUZZ_AGENT_THINKING_EFFORT default: mesh translates // `reasoning_effort` into the chat template's `enable_thinking` flag, so any // value we pick overrides each model's own template default — and the right @@ -80,7 +87,15 @@ pub fn relay_mesh_process_env( model: &str, ) -> std::collections::BTreeMap { let mut env = std::collections::BTreeMap::new(); - for key in ["BUZZ_AGENT_MAX_OUTPUT_TOKENS", "BUZZ_AGENT_THINKING_EFFORT"] { + for key in [ + "BUZZ_AGENT_MAX_OUTPUT_TOKENS", + "BUZZ_AGENT_THINKING_EFFORT", + // Must be copied forward for the user's value to survive: this map is + // written onto the command *after* the layered user env, so a key absent + // here is re-defaulted by `apply_relay_mesh_env` below and an explicit + // `BUZZ_AGENT_REQUIRE_REPLY=0` would be silently overridden back to `1`. + "BUZZ_AGENT_REQUIRE_REPLY", + ] { if let Some(value) = effective_env.get(key) { env.insert(key.to_string(), value.clone()); } @@ -145,6 +160,78 @@ mod tests { ); } + #[test] + fn native_provider_enables_reply_guard_by_default() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1"), + "mesh agents opt into the reply guard automatically" + ); + } + + #[test] + fn native_provider_preserves_explicit_reply_guard_opt_out() { + let mut env = BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + apply_relay_mesh_env( + &mut env, + Some(RELAY_MESH_PROVIDER_ID), + Some(RELAY_MESH_AUTO_MODEL_ID), + ); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0"), + "an explicit opt-out is a user decision, not a value to re-default" + ); + } + + #[test] + fn non_mesh_provider_leaves_reply_guard_unset() { + let mut env = BTreeMap::new(); + apply_relay_mesh_env(&mut env, Some("anthropic"), Some("claude-haiku-4.5")); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY"), + None, + "the guard stays opt-in everywhere except mesh" + ); + assert!(env.is_empty(), "non-mesh providers get no mesh env at all"); + } + + /// The spawn path writes this map onto the command *after* the layered user + /// env, so an explicit opt-out only survives if it is copied forward. Without + /// the copy-forward, `apply_relay_mesh_env` re-defaults it to `1` here and + /// silently overrides the user at spawn while readiness still shows `0`. + #[test] + fn process_env_preserves_explicit_reply_guard_opt_out() { + let effective_env = + BTreeMap::from([("BUZZ_AGENT_REQUIRE_REPLY".to_string(), "0".to_string())]); + + let env = relay_mesh_process_env(&effective_env, "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("0") + ); + } + + #[test] + fn process_env_enables_reply_guard_when_user_is_silent() { + let env = relay_mesh_process_env(&BTreeMap::new(), "Gemma-4"); + + assert_eq!( + env.get("BUZZ_AGENT_REQUIRE_REPLY").map(String::as_str), + Some("1") + ); + } + #[test] fn process_env_seeds_controls_without_restoring_unrelated_credentials() { let effective_env = BTreeMap::from([ diff --git a/docs/MCP_DRIVEN_HOOKS.md b/docs/MCP_DRIVEN_HOOKS.md index e510c378e7..6812a5b9a6 100644 --- a/docs/MCP_DRIVEN_HOOKS.md +++ b/docs/MCP_DRIVEN_HOOKS.md @@ -65,6 +65,23 @@ These constraints ensure a buggy or malicious hook cannot trap the agent. Hooks are **off by default**. The operator must explicitly opt in via `MCP_HOOK_SERVERS`. +### Not a hook: the reply guard + +`buzz-agent` has one in-process objection at the `_Stop` gate that is **not** an +MCP hook and exposes no hook tool: the reply guard +(`BUZZ_AGENT_REQUIRE_REPLY=1`), which reminds the model to publish when a turn is +about to end with nothing posted to Buzz. There is no `_ReplyGuard` tool to +implement and no server to allowlist — the env var and the recognition contract +are documented in +[crates/buzz-agent/README.md](../crates/buzz-agent/README.md#reply-guard). + +It is mentioned here only because it shares this lifecycle point and this +budget: its reminders count against `BUZZ_AGENT_STOP_MAX_REJECTIONS` like any +hook objection, and a round carrying both a hook objection and a reminder costs +one rejection and delivers both texts. Setting the budget to 0 disables both. +That the gate can carry in-process objections alongside hook output is +deliberate; hooks see no difference. + ## Implementing a Hook Any MCP server can expose hooks. Example: a test-runner server that blocks From 4632c55041c5d423d572a6f6411bb7b279c26f67 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 31 Jul 2026 07:06:41 -0400 Subject: [PATCH 35/87] feat(desktop): auto-enable huddle transcription for agents (#3180) ## Context Before this change, every huddle initialized with transcription off. Joining or adding an agent did not enable it, so the agent could not receive spoken conversation until a person clicked the transcript control. Starting a huddle from an agent DM could also omit that agent, and adding an agent who already belonged to the parent channel could attempt an unnecessary role change and show a warning. Agent detection uses authoritative huddle membership. A participant counts as an agent when the ephemeral membership identifies it with the `bot` role, or when the existing agent identity model identifies the participant in an agent DM. ## Summary Buzz now enables transcription once when the first authoritative agent is present. After that initial automatic action, explicit user control is authoritative: manual ON or OFF survives membership refreshes, reconnects, and UI remounts. Removing the last agent does not change the current transcription state. Agent-DM huddles enroll the agent automatically. Adding an agent who already belongs to the parent channel preserves the existing parent role and completes without a role-mutation warning. | Scenario | Before | With this change | | --- | --- | --- | | First authoritative agent joins or is hydrated | Transcription stays off | Transcription turns on once | | User explicitly turns transcription on or off | Manual control exists without an agent policy | The explicit choice suppresses later automatic changes | | Last agent leaves | No defined agent-presence behavior | The current transcription state remains unchanged | | Huddle starts from an agent DM | The agent can be omitted | The known agent is enrolled automatically | | Added agent already belongs to the parent channel | Buzz can attempt a role rewrite and warn | Existing parent membership and role are preserved | | Transcription is active | The control is not visually distinct | The control is highlighted and exposes `aria-pressed=true` | ## Changes - Derive agent presence from authoritative bot-role huddle membership and known agent-DM identity. - Apply the one-time auto-enable rule during create, join, membership hydration, reconnect, pipeline startup, and local agent addition. - Preserve explicit user state and use huddle-generation guards so stale asynchronous work cannot alter a replacement huddle. - Keep backend and React transcription state synchronized, with a visible and accessible active control. - Enroll known agent-DM participants and make parent-channel membership updates idempotent. - Cover hydration ordering, reconnects, remounts, explicit OFF, last-agent removal, DM enrollment, existing membership, and active styling. ## Related issue None found. ## Testing Manual validation in `pending-seed` confirmed the product contract: 1. Started a huddle from the owned, running Fizz agent DM. 2. Confirmed the authoritative roster contained the human and Fizz as an agent. 3. Confirmed transcription enabled without clicking the control: `Stop transcript`, `aria-pressed=true`, with the highlighted active background. 4. Turned transcription off and confirmed `Start transcript`, `aria-pressed=false` remained stable. 5. Removed Fizz while transcription was off and confirmed the state stayed off. 6. Left the huddle cleanly. ## Screenshots The same control has distinct active and inactive states. ![Active transcript control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--active-transcription.png) ![Inactive transcript control](https://raw.githubusercontent.com/block/buzz/2dcb266244e93d358f85e5371d190de77b03c86d/pr-3180--inactive-transcription.png) ## Reviewer-reproducible examples From a fresh checkout: ```bash pnpm --dir desktop build:e2e pnpm --dir desktop exec playwright test tests/e2e/huddle-transcription.spec.ts --project=smoke pnpm --dir desktop exec playwright test tests/e2e/mentions.spec.ts --project=smoke --grep "system agent profile exposes owned agent actions|system agent avatar exposes owned agent actions|owned bot profile exposes message and huddle actions|owned agent mention profile exposes message and huddle actions" ``` The huddle scenario exercises initial authoritative hydration, exactly one automatic enable, explicit OFF persistence, unchanged state after last-agent removal, newer events winning over delayed hydration, agent-DM enrollment, and idempotent parent membership. It also asserts `aria-pressed` and distinct computed active styling. --------- Signed-off-by: John Tennant Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Co-authored-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- desktop/playwright.config.ts | 1 + desktop/src-tauri/src/huddle/agents.rs | 66 +++- desktop/src-tauri/src/huddle/mod.rs | 303 +++++++----------- desktop/src-tauri/src/huddle/pipeline.rs | 269 +++++++++++++--- desktop/src-tauri/src/huddle/state.rs | 239 +++++++++++++- desktop/src-tauri/src/huddle/transcription.rs | 27 +- .../channels/ui/ChannelMembersBar.tsx | 47 ++- desktop/src/features/huddle/HuddleContext.tsx | 30 +- .../features/huddle/components/HuddleBar.tsx | 12 +- .../profile/ui/UserProfilePopover.tsx | 18 +- .../src/shared/styles/globals/utilities.css | 9 + desktop/src/testing/e2eBridge.ts | 205 ++++++++++-- .../tests/e2e/huddle-transcription.spec.ts | 254 +++++++++++++++ desktop/tests/e2e/mentions.spec.ts | 38 ++- desktop/tests/helpers/bridge.ts | 21 ++ 15 files changed, 1232 insertions(+), 307 deletions(-) create mode 100644 desktop/tests/e2e/huddle-transcription.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index bba9218a1a..2b885e3e53 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -131,6 +131,7 @@ export default defineConfig({ "**/harness-management.spec.ts", "**/harness-catalog-screenshots.spec.ts", "**/inline-custom-harness.spec.ts", + "**/huddle-transcription.spec.ts", ], use: { ...devices["Desktop Chrome"], diff --git a/desktop/src-tauri/src/huddle/agents.rs b/desktop/src-tauri/src/huddle/agents.rs index 02c4045410..2de22f99d8 100644 --- a/desktop/src-tauri/src/huddle/agents.rs +++ b/desktop/src-tauri/src/huddle/agents.rs @@ -2,7 +2,8 @@ //! //! Mental model: //! add_agent_to_huddle → kind:9000 to ephemeral channel -//! → kind:9000 to parent channel (best-effort) +//! → preserve existing parent membership, or +//! kind:9000 to parent channel (best-effort) //! //! ACP spawning is NOT needed here: the running agent process auto-subscribes //! when it receives the kind:9000 membership notification. Huddle-specific @@ -11,7 +12,10 @@ use serde::Serialize; use uuid::Uuid; -use crate::{app_state::AppState, events, relay::submit_event}; +use crate::{ + app_state::AppState, events, huddle::relay_api::fetch_channel_members_with_roles, + relay::submit_event, +}; // ── Constants ───────────────────────────────────────────────────────────────── @@ -61,8 +65,9 @@ with the next one. /// The field exists for forward compatibility with future batch-add operations /// where partial success may be meaningful. /// -/// `parent_added` reflects whether the parent-channel add succeeded; -/// `parent_error` carries the error string when it didn't. +/// `parent_added` reflects whether the parent already contained the agent or +/// the parent-channel add succeeded; `parent_error` carries the error string +/// when neither condition could be confirmed. #[derive(Debug, Serialize)] pub struct AgentAddResult { /// Always `true` — invariant guaranteed by [`add_agent_to_huddle`]. @@ -91,17 +96,33 @@ pub async fn add_agent_to_huddle( let add_eph = events::build_add_member(ephemeral_channel_id, agent_pubkey, Some("bot"))?; submit_event(add_eph, state).await?; - // 2. Add agent to parent channel — so agent has full context. - // Best-effort: capture the error but don't propagate it. - let (parent_added, parent_error) = { + // 2. Preserve any active parent membership, regardless of role. Rewriting + // an existing DM member as `bot` is both unnecessary and forbidden for + // non-admins. Otherwise add the agent so it has full context. + // Best-effort: capture a real error but don't propagate it. + let parent_channel_id_string = parent_channel_id.to_string(); + let parent_already_contains_agent = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + + let (parent_added, parent_error) = if parent_already_contains_agent { + (true, None) + } else { let add_parent = events::build_add_member(parent_channel_id, agent_pubkey, Some("bot"))?; match submit_event(add_parent, state).await { Ok(_) => (true, None), Err(e) => { - eprintln!( - "buzz-desktop: add agent to parent channel failed (may already be member): {e}" - ); - (false, Some(e)) + let active_after_error = + fetch_channel_members_with_roles(&parent_channel_id_string, state) + .await + .is_ok_and(|members| contains_member(&members, agent_pubkey)); + if active_after_error { + (true, None) + } else { + eprintln!("buzz-desktop: add agent to parent channel failed: {e}"); + (false, Some(e)) + } } } }; @@ -112,3 +133,26 @@ pub async fn add_agent_to_huddle( parent_error, }) } + +fn contains_member(members: &[(String, Option)], pubkey: &str) -> bool { + members + .iter() + .any(|(member_pubkey, _)| member_pubkey.eq_ignore_ascii_case(pubkey)) +} + +#[cfg(test)] +mod tests { + use super::contains_member; + + #[test] + fn existing_parent_membership_is_preserved_regardless_of_role() { + let members = vec![ + ("agent-member".to_owned(), Some("member".to_owned())), + ("agent-bot".to_owned(), Some("bot".to_owned())), + ]; + + assert!(contains_member(&members, "AGENT-MEMBER")); + assert!(contains_member(&members, "agent-bot")); + assert!(!contains_member(&members, "missing")); + } +} diff --git a/desktop/src-tauri/src/huddle/mod.rs b/desktop/src-tauri/src/huddle/mod.rs index a815bf2d06..0a84cffe00 100644 --- a/desktop/src-tauri/src/huddle/mod.rs +++ b/desktop/src-tauri/src/huddle/mod.rs @@ -66,13 +66,16 @@ pub use transcription::{set_huddle_transcription_enabled, start_stt_pipeline}; // ── Imports ─────────────────────────────────────────────────────────────────── -use std::sync::{atomic::Ordering, Arc}; +use std::sync::atomic::Ordering; use tauri::State; use uuid::Uuid; use crate::{app_state::AppState, events, relay::submit_event}; - -use pipeline::{maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup}; +pub use pipeline::check_pipeline_hotstart; +use pipeline::{ + maybe_start_stt_pipeline, maybe_start_tts_pipeline, post_connect_setup, + start_auto_enabled_transcription, PostConnectOutcome, +}; use relay_api::{ count_human_members, fetch_channel_members, parse_channel_uuid, validate_pubkey_hex, MAX_HUDDLE_AGENTS, @@ -186,7 +189,7 @@ pub async fn start_huddle( }; // Transition to Creating. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -194,9 +197,11 @@ pub async fn start_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Creating; hs.parent_channel_id = Some(parent_channel_id.clone()); - } + generation + }; let ephemeral_uuid = Uuid::new_v4(); let ephemeral_channel_id = ephemeral_uuid.to_string(); @@ -259,27 +264,33 @@ pub async fn start_huddle( match result { Ok(successful_agents) => { // 5. Store active state. - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - hs.is_creator = true; - hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - // Only store agents that were successfully enrolled. - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = - successful_agents.clone(); - // Include the current user + successfully enrolled agents as participants. - // Use successful_agents (not member_pubkeys) so failed enrollments - // are not reflected in the participant list. - let own_pubkey = state - .keys - .lock() - .map(|k| k.public_key().to_hex()) - .unwrap_or_default(); - let mut participants = successful_agents.clone(); - if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { - participants.insert(0, own_pubkey); + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + false + } else { + hs.phase = HuddlePhase::Connected; + hs.is_creator = true; + hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = + successful_agents.clone(); + hs.maybe_auto_enable_transcription_for_agents(); + let own_pubkey = state + .keys + .lock() + .map(|k| k.public_key().to_hex()) + .unwrap_or_default(); + let mut participants = successful_agents.clone(); + if !own_pubkey.is_empty() && !participants.contains(&own_pubkey) { + participants.insert(0, own_pubkey); + } + hs.participants = participants; + true } - hs.participants = participants; + }; + if !committed { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; + return Err("huddle start was superseded".to_owned()); } // 6. Notify frontend of state change. @@ -287,16 +298,30 @@ pub async fn start_huddle( // 7. Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Publish the terminal lifecycle event before archiving so - // other clients do not reconstruct a phantom active huddle. - emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state).await; - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle start was superseded".to_owned()); + } + Err(e) => { + // Roll back only if this failed setup still owns the active + // huddle. A stale failure must not tear down its replacement. + let still_current = state + .huddle() + .map(|hs| hs.is_current_huddle(&ephemeral_channel_id, huddle_generation)) + .unwrap_or(false); + if still_current { + emit_end_and_archive(&parent_channel_id, &ephemeral_channel_id, &state) + .await; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + } + } + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -314,11 +339,11 @@ pub async fn start_huddle( } } } - // Reset state to Idle so the user can retry. - // Preserve session_generation so in-flight transcription tasks - // from a prior session still see a stale generation and exit. + // Reset only if this failed attempt still owns the Creating state. if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + if hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Creating) { + hs.reset_preserving_generation(); + } } Err(e) } @@ -340,7 +365,7 @@ pub async fn join_huddle( state: State<'_, AppState>, ) -> Result { // Transition to Connecting. - { + let huddle_generation = { let mut hs = state.huddle()?; if hs.phase != HuddlePhase::Idle { return Err(format!( @@ -348,10 +373,12 @@ pub async fn join_huddle( hs.phase )); } + let generation = hs.begin_huddle_lifetime(); hs.phase = HuddlePhase::Connecting; hs.parent_channel_id = Some(parent_channel_id.clone()); hs.ephemeral_channel_id = Some(ephemeral_channel_id.clone()); - } + generation + }; // Seed participant list with own pubkey as a fallback until relay responds. let own_pubkey = state @@ -360,12 +387,20 @@ pub async fn join_huddle( .map(|k| k.public_key().to_hex()) .unwrap_or_default(); - { + let committed = { let mut hs = state.huddle()?; - hs.phase = HuddlePhase::Connected; - if !own_pubkey.is_empty() { - hs.participants = vec![own_pubkey]; + if !hs.owns_huddle_lifetime(huddle_generation, HuddlePhase::Connecting) { + false + } else { + hs.phase = HuddlePhase::Connected; + if !own_pubkey.is_empty() { + hs.participants = vec![own_pubkey]; + } + true } + }; + if !committed { + return Err("huddle join was superseded".to_owned()); } // Notify frontend of state change. @@ -373,15 +408,25 @@ pub async fn join_huddle( // Hydrate members, download models, start pipelines (incl. audio relay). // Audio relay failure is fatal — no point in a huddle without audio. - if let Err(e) = post_connect_setup(&state, &ephemeral_channel_id).await { - // Rollback: audio relay failed after state was committed. - // Reset state to Idle so the user can retry. The ephemeral channel - // has a TTL and will expire — no manual archive needed for joiners. - if let Ok(mut hs) = state.huddle_state.lock() { - hs.reset_preserving_generation(); + match post_connect_setup(&state, &ephemeral_channel_id, huddle_generation).await { + Ok(PostConnectOutcome::Ready) => {} + Ok(PostConnectOutcome::Stale) => { + return Err("huddle join was superseded".to_owned()); + } + Err(e) => { + // Reset only the huddle lifetime that failed. + let mut did_reset = false; + if let Ok(mut hs) = state.huddle_state.lock() { + if hs.is_current_huddle(&ephemeral_channel_id, huddle_generation) { + hs.reset_preserving_generation(); + did_reset = true; + } + } + if did_reset { + state.emit_huddle_state_changed(); + } + return Err(e); } - state.emit_huddle_state_changed(); - return Err(e); } Ok(HuddleJoinInfo { @@ -675,123 +720,6 @@ pub fn push_audio_pcm( } } -/// Hot-start: check if voice models just finished downloading during an active -/// huddle and start the corresponding pipelines. -/// -/// Called by the frontend on a timer or after model status changes. No-op if -/// the huddle is not active or pipelines are already running. -#[tauri::command] -pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { - let (is_active, ephemeral_channel_id) = { - let hs = state.huddle()?; - ( - matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), - hs.ephemeral_channel_id.clone(), - ) - }; - - if !is_active { - return Ok(()); - } - - // Detect dead pipelines: if the worker thread has exited (init failure or crash), - // clear the pipeline handle so hot-start can retry on the next cycle. - { - let mut hs = state.huddle()?; - if let Some(ref p) = hs.stt_pipeline { - if p.is_finished() { - hs.stt_pipeline = None; - } - } - if let Some(ref p) = hs.tts_pipeline { - if p.is_finished() { - hs.tts_pipeline = None; - } - } - } - // Re-read after potential cleanup. - let (has_stt, has_tts, transcription_enabled) = { - let hs = state.huddle()?; - ( - hs.stt_pipeline.is_some(), - hs.tts_pipeline.is_some(), - hs.transcription_enabled, - ) - }; - - // Check if models just became ready (one-shot flags). - let stt_ready = models::global_model_manager() - .map(|m| m.take_stt_ready()) - .unwrap_or(false); - let tts_ready = models::global_model_manager() - .map(|m| m.take_tts_ready()) - .unwrap_or(false); - - // Start TTS first (so STT can capture tts_cancel). - if !has_tts && (tts_ready || models::is_tts_ready()) { - if let Err(e) = maybe_start_tts_pipeline(&state).await { - eprintln!("buzz-desktop: TTS hotstart failed: {e}"); - } - } - - if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { - if let Some(eph_id) = &ephemeral_channel_id { - if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { - eprintln!("buzz-desktop: STT hotstart failed: {e}"); - } - } - } - - // Periodically refresh agent_pubkeys from relay membership. - // This catches mid-huddle agent additions/removals by other participants, - // keeping STT p-tags authoritative throughout the session. - // Throttled to every 15 s (not on every 5 s hotstart poll). - // - // NOTE: The frontend ALSO polls agent membership independently (every 10 s - // via get_huddle_agent_pubkeys). This is intentional — the two polls have - // different failure semantics: - // - Rust (here): preserves stale list on failure (STT p-tags should not - // disappear on a transient network blip). - // - React (HuddleContext.tsx): clears list on failure (TTS authorization - // must fail-closed — never speak from a stale agent list). - // - // On Ok: always replace (even with empty — agents may have been removed). - // On Err: preserve the existing list (transient failure shouldn't zero it). - if let Some(eph_id) = &ephemeral_channel_id { - let should_refresh = { - let hs = state.huddle()?; - match hs.last_agent_refresh { - None => true, - Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), - } - }; - if should_refresh { - // Fetch agents (for STT p-tags) and all members (for participant list). - // Sequential — tokio::join! requires the `macros` feature. - // Only update the throttle timestamp when at least one fetch succeeds, - // so transient failures retry immediately on the next poll cycle. - // Fetch both lists before acquiring the lock — no lock held across await. - let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) - .await - .ok(); - let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); - - if fresh_agents.is_some() || fresh_members.is_some() { - let mut hs = state.huddle()?; - if let Some(agents) = fresh_agents { - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - if let Some(members) = fresh_members { - hs.participants = members; - } - hs.last_agent_refresh = Some(std::time::Instant::now()); - } - } - } - - Ok(()) -} - /// Trigger a background download of voice models (Parakeet STT + Pocket TTS). /// /// Returns immediately — downloads run in tokio background tasks. @@ -924,7 +852,7 @@ pub async fn add_agent_to_huddle( ) -> Result { validate_pubkey_hex(&agent_pubkey)?; - let (eph_id, parent_id) = { + let (eph_id, parent_id, huddle_generation) = { let hs = state.huddle()?; if !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) { return Err("no active huddle".to_string()); @@ -948,7 +876,7 @@ pub async fn add_agent_to_huddle( .clone() .ok_or("no ephemeral channel")?; let parent = hs.parent_channel_id.clone().ok_or("no parent channel")?; - (eph, parent) + (eph, parent, hs.huddle_generation) }; let eph_uuid = Uuid::parse_str(&eph_id).map_err(|e| e.to_string())?; @@ -957,29 +885,30 @@ pub async fn add_agent_to_huddle( // Returns Err only if the ephemeral add fails — parent failure is in the result. let result = agents::add_agent_to_huddle(eph_uuid, parent_uuid, &agent_pubkey, &state).await?; - // Ephemeral add succeeded — safe to register for p-tagging. - // Clone the Arc first so we can drop the outer HuddleState lock before - // acquiring the inner pubkeys lock (avoids the E0597 borrow-checker error). - { - let agent_pubkeys_arc = { - let hs = state.huddle()?; - Arc::clone(&hs.agent_pubkeys) - }; - let mut pubkeys = agent_pubkeys_arc.lock().unwrap_or_else(|e| e.into_inner()); + // Ephemeral add succeeded — register it only if this is still the huddle + // that initiated the relay operation. + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(&eph_id, huddle_generation) { + return Ok(result); + } + let mut pubkeys = hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()); if !pubkeys.contains(&agent_pubkey) { pubkeys.push(agent_pubkey.clone()); } - } + drop(pubkeys); + if !hs.participants.contains(&agent_pubkey) { + hs.participants.push(agent_pubkey.clone()); + } + hs.maybe_auto_enable_transcription_for_agents() + }; // No guidelines re-post needed — the agent sees the original kind:48106 // guidelines via EOSE replay when it subscribes to the ephemeral channel. - - // Also add the agent to the visible participants list. - { - let mut hs = state.huddle()?; - if !hs.participants.contains(&agent_pubkey) { - hs.participants.push(agent_pubkey); - } + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, &eph_id).await; + } else { + state.emit_huddle_state_changed(); } Ok(result) diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 6a4cf26201..18b688a971 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -9,6 +9,7 @@ use std::sync::{ }; use nostr::JsonUtil; +use tauri::State; use uuid::Uuid; use crate::app_state::AppState; @@ -20,55 +21,224 @@ use super::state::{HuddlePhase, VoiceInputMode}; use super::stt; use super::tts; +pub(crate) enum PostConnectOutcome { + Ready, + Stale, +} + +/// Hot-start: check if voice models just finished downloading during an active +/// huddle and start the corresponding pipelines. +/// +/// Called by the frontend on a timer or after model status changes. No-op if +/// the huddle is not active or pipelines are already running. +#[tauri::command] +pub async fn check_pipeline_hotstart(state: State<'_, AppState>) -> Result<(), String> { + let (is_active, ephemeral_channel_id, huddle_generation) = { + let hs = state.huddle()?; + ( + matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active), + hs.ephemeral_channel_id.clone(), + hs.huddle_generation, + ) + }; + + if !is_active { + return Ok(()); + } + + // Detect dead pipelines: if the worker thread has exited (init failure or crash), + // clear the pipeline handle so hot-start can retry on the next cycle. + { + let mut hs = state.huddle()?; + if let Some(ref p) = hs.stt_pipeline { + if p.is_finished() { + hs.stt_pipeline = None; + } + } + if let Some(ref p) = hs.tts_pipeline { + if p.is_finished() { + hs.tts_pipeline = None; + } + } + } + // Re-read after potential cleanup. + let (has_stt, has_tts, transcription_enabled) = { + let hs = state.huddle()?; + ( + hs.stt_pipeline.is_some(), + hs.tts_pipeline.is_some(), + hs.transcription_enabled, + ) + }; + + // Check if models just became ready (one-shot flags). + let stt_ready = models::global_model_manager() + .map(|m| m.take_stt_ready()) + .unwrap_or(false); + let tts_ready = models::global_model_manager() + .map(|m| m.take_tts_ready()) + .unwrap_or(false); + + // Start TTS first (so STT can capture tts_cancel). + if !has_tts && (tts_ready || models::is_tts_ready()) { + if let Err(e) = maybe_start_tts_pipeline(&state).await { + eprintln!("buzz-desktop: TTS hotstart failed: {e}"); + } + } + if transcription_enabled && !has_stt && (stt_ready || models::is_stt_ready()) { + if let Some(eph_id) = &ephemeral_channel_id { + if let Err(e) = maybe_start_stt_pipeline(&state, eph_id).await { + eprintln!("buzz-desktop: STT hotstart failed: {e}"); + } + } + } + + // Periodically refresh agent membership from the relay. + // This catches mid-huddle additions/removals by other participants, keeps + // STT p-tags authoritative, and auto-enables transcription when the first + // agent appears unless the user has already chosen a transcription state. + // Throttled independently from the more frequent hotstart poll. + // + // NOTE: The frontend ALSO polls agent membership independently via + // get_huddle_agent_pubkeys. This is intentional — the two polls have + // different failure semantics: + // - Rust (here): preserves stale list on failure (STT p-tags should not + // disappear on a transient network blip). + // - React (HuddleContext.tsx): clears list on failure (TTS authorization + // must fail-closed — never speak from a stale agent list). + // + // On Ok: always replace (even with empty — agents may have been removed). + // On Err: preserve the existing list (transient failure shouldn't zero it). + if let Some(eph_id) = &ephemeral_channel_id { + let should_refresh = { + let hs = state.huddle()?; + match hs.last_agent_refresh { + None => true, + Some(t) => t.elapsed() >= std::time::Duration::from_secs(15), + } + }; + if should_refresh { + // Fetch agents (for STT p-tags) before all members (for participant + // list) so relay membership queries remain ordered. + // Only update the throttle timestamp when at least one fetch succeeds, + // so transient failures retry immediately on the next poll cycle. + // Fetch both lists before acquiring the lock — no lock held across await. + let fresh_agents = fetch_channel_members(eph_id, Some("bot"), &state) + .await + .ok(); + let fresh_members = fetch_channel_members(eph_id, None, &state).await.ok(); + let transcription_auto_enabled = if fresh_agents.is_some() || fresh_members.is_some() { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(eph_id, huddle_generation) { + return Ok(()); + } + if let Some(agents) = fresh_agents { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Some(members) = fresh_members { + hs.participants = members; + } + hs.last_agent_refresh = Some(std::time::Instant::now()); + hs.maybe_auto_enable_transcription_for_agents() + } else { + false + }; + if transcription_auto_enabled { + start_auto_enabled_transcription(&state, eph_id).await; + } + } + } + + Ok(()) +} + pub(crate) async fn post_connect_setup( state: &AppState, ephemeral_channel_id: &str, -) -> Result<(), String> { + huddle_generation: u64, +) -> Result { + { + let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } + } + // Hydrate agent pubkeys and participants from relay in parallel // (authoritative — overrides local guesses). let (agents_result, all_members_result) = tokio::join!( fetch_channel_members(ephemeral_channel_id, Some("bot"), state), fetch_channel_members(ephemeral_channel_id, None, state), ); - if let Ok(agents) = agents_result { - let hs = state.huddle()?; - *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; - } - - if let Ok(all_members) = all_members_result { - if !all_members.is_empty() { - let mut hs = state.huddle()?; - hs.participants = all_members; + let transcription_auto_enabled = { + let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); } + if let Ok(agents) = agents_result { + *hs.agent_pubkeys.lock().unwrap_or_else(|e| e.into_inner()) = agents; + } + if let Ok(all_members) = all_members_result { + if !all_members.is_empty() { + hs.participants = all_members; + } + } + hs.maybe_auto_enable_transcription_for_agents() + }; + + if transcription_auto_enabled { + state.emit_huddle_state_changed(); } - // Prepare TTS for agent voice. STT is transcript-specific and starts only - // when transcription is explicitly enabled. + // Prepare voice models. Agent presence may have auto-enabled transcription; + // explicit user choices remain authoritative. if let Some(mgr) = models::global_model_manager() { mgr.start_tts_download(state.http_client.clone()); + if state.huddle()?.transcription_enabled { + mgr.start_stt_download(state.http_client.clone()); + } } // Connect audio relay WebSocket (Opus encode/decode pipeline). // This is the core audio path — failure is fatal for the huddle. let parent_id = { let hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(PostConnectOutcome::Stale); + } hs.parent_channel_id.clone() }; - let (cancel, pcm_tx) = - relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await?; + let audio_result = + relay_api::connect_audio_relay(ephemeral_channel_id, parent_id.as_deref(), state).await; { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + if let Ok((cancel, _)) = audio_result { + cancel.cancel(); + } + return Ok(PostConnectOutcome::Stale); + } + let (cancel, pcm_tx) = audio_result?; hs.audio_ws_cancel = Some(cancel); hs.audio_relay_pcm_tx = Some(pcm_tx); } - // Start TTS immediately. STT/transcript posting is opt-in and starts only - // after the user explicitly enables transcription. + // Start TTS immediately, then STT when transcription is enabled either by + // the user or by authoritative agent membership. + if !state + .huddle()? + .is_current_huddle(ephemeral_channel_id, huddle_generation) + { + return Ok(PostConnectOutcome::Stale); + } if let Err(e) = maybe_start_tts_pipeline(state).await { eprintln!("buzz-desktop: TTS pipeline failed to start: {e}"); } + if let Err(e) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: STT pipeline failed to start: {e}"); + } - Ok(()) + Ok(PostConnectOutcome::Ready) } /// Attempt to start the STT pipeline if models are present. @@ -83,12 +253,16 @@ pub(crate) async fn maybe_start_stt_pipeline( state: &AppState, ephemeral_channel_id: &str, ) -> Result { - { + let huddle_generation = { let hs = state.huddle()?; - if !hs.transcription_enabled { + if !hs.transcription_enabled + || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || hs.ephemeral_channel_id.as_deref() != Some(ephemeral_channel_id) + { return Ok(false); } - } + hs.huddle_generation + }; if !models::is_stt_ready() { return Ok(false); // Models not downloaded yet — voice-only mode. @@ -97,21 +271,29 @@ pub(crate) async fn maybe_start_stt_pipeline( let channel_uuid = parse_channel_uuid(ephemeral_channel_id)?; - // Atomically claim the construction slot (mirrors tts_starting pattern). - { - let hs = state.huddle()?; - if hs.stt_starting.swap(true, Ordering::AcqRel) { - return Ok(false); // Another caller is already constructing. - } - } - - // Grab shared flags, agent pubkeys, and session generation from HuddleState. + // Atomically claim construction and grab shared state under one lock. // If replacing an existing pipeline, bump generation first so the old // transcription task's next POST sees a stale generation and exits. // Take the old pipeline OUT of the lock before dropping — Drop joins // the worker thread (~200ms) and must not block under the mutex. - let (tts_active, tts_cancel, agent_pubkeys_arc, session_gen, ptt_active_for_stt, old_stt) = { + let ( + tts_active, + tts_cancel, + agent_pubkeys_arc, + session_gen, + expected_generation, + stt_starting, + ptt_active_for_stt, + old_stt, + ) = { let mut hs = state.huddle()?; + if !hs.is_current_huddle(ephemeral_channel_id, huddle_generation) { + return Ok(false); + } + if hs.stt_starting.swap(true, Ordering::AcqRel) { + return Ok(false); + } + let stt_starting = Arc::clone(&hs.stt_starting); // Invalidate any existing transcription task before replacing the pipeline. if hs.stt_pipeline.is_some() { hs.session_generation.fetch_add(1, Ordering::Release); @@ -130,6 +312,8 @@ pub(crate) async fn maybe_start_stt_pipeline( Some(Arc::clone(&hs.tts_cancel)), Arc::clone(&hs.agent_pubkeys), Arc::clone(&hs.session_generation), + hs.session_generation.load(Ordering::Acquire), + stt_starting, ptt, old, ) @@ -144,13 +328,11 @@ pub(crate) async fn maybe_start_stt_pipeline( let (pipeline, text_rx) = match constructed { Ok(Ok(p)) => p, Ok(Err(e)) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(e); } Err(e) => { - let hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); return Err(format!("spawn_blocking failed: {e}")); } }; @@ -158,10 +340,14 @@ pub(crate) async fn maybe_start_stt_pipeline( { let mut hs = state.huddle()?; - hs.stt_starting.store(false, Ordering::Release); + stt_starting.store(false, Ordering::Release); // Phase check: huddle may have been torn down during construction. if !hs.transcription_enabled - || !matches!(hs.phase, HuddlePhase::Connected | HuddlePhase::Active) + || !hs.is_current_transcription_generation( + ephemeral_channel_id, + huddle_generation, + expected_generation, + ) { return Ok(false); } @@ -172,6 +358,17 @@ pub(crate) async fn maybe_start_stt_pipeline( Ok(true) } +/// Start STT after agent presence automatically enables transcription. +pub(crate) async fn start_auto_enabled_transcription(state: &AppState, ephemeral_channel_id: &str) { + if let Some(manager) = models::global_model_manager() { + manager.start_stt_download(state.http_client.clone()); + } + if let Err(error) = maybe_start_stt_pipeline(state, ephemeral_channel_id).await { + eprintln!("buzz-desktop: auto-enabled STT failed to start: {error}"); + } + state.emit_huddle_state_changed(); +} + /// Attempt to start the TTS pipeline if TTS models are present and TTS is enabled. /// /// Returns `Ok(true)` if the pipeline was started, `Ok(false)` if preconditions diff --git a/desktop/src-tauri/src/huddle/state.rs b/desktop/src-tauri/src/huddle/state.rs index 876c2d688b..f0a2227ca8 100644 --- a/desktop/src-tauri/src/huddle/state.rs +++ b/desktop/src-tauri/src/huddle/state.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use std::sync::{ - atomic::{AtomicBool, AtomicU64}, + atomic::{AtomicBool, AtomicU64, Ordering}, Arc, Mutex, }; @@ -80,6 +80,15 @@ pub struct HuddleState { pub tts_enabled: bool, /// Whether STT transcript posting is enabled for this huddle. pub transcription_enabled: bool, + /// Whether the user has explicitly used the transcription control in this + /// huddle. Agent presence may auto-enable transcription only while this is + /// false, so membership refreshes never undo an explicit user choice. + /// + /// This is backend-only session state: keeping it in `HuddleState` makes it + /// survive frontend remounts and audio reconnects, while huddle teardown + /// resets it for the next session. + #[serde(skip)] + pub transcription_user_controlled: bool, /// Shared flag: true while TTS is playing audio. /// Shared with the STT pipeline for barge-in / echo gating. #[serde(skip)] @@ -103,6 +112,10 @@ pub struct HuddleState { /// Used to throttle the refresh in check_pipeline_hotstart to every 15 s. #[serde(skip)] pub last_agent_refresh: Option, + /// Monotonic identity for a local huddle lifetime. Unlike transcript + /// generation, this changes only when a new start/join attempt begins. + #[serde(skip)] + pub huddle_generation: u64, /// Session generation — incremented on every teardown. The transcription /// task captures this at spawn time and checks before each POST. If the /// generation has changed, the task silently drops the transcript. @@ -157,11 +170,13 @@ impl Clone for HuddleState { is_creator: self.is_creator, tts_enabled: self.tts_enabled, transcription_enabled: self.transcription_enabled, + transcription_user_controlled: self.transcription_user_controlled, tts_active: Arc::clone(&self.tts_active), tts_cancel: Arc::clone(&self.tts_cancel), tts_starting: Arc::clone(&self.tts_starting), stt_starting: Arc::clone(&self.stt_starting), last_agent_refresh: self.last_agent_refresh, + huddle_generation: self.huddle_generation, session_generation: Arc::clone(&self.session_generation), voice_input_mode: self.voice_input_mode.clone(), ptt_active: Arc::clone(&self.ptt_active), @@ -184,11 +199,13 @@ impl Default for HuddleState { is_creator: false, tts_enabled: true, transcription_enabled: false, + transcription_user_controlled: false, tts_active: Arc::new(AtomicBool::new(false)), tts_cancel: Arc::new(AtomicBool::new(false)), tts_starting: Arc::new(AtomicBool::new(false)), stt_starting: Arc::new(AtomicBool::new(false)), last_agent_refresh: None, + huddle_generation: 0, session_generation: Arc::new(AtomicU64::new(0)), voice_input_mode: VoiceInputMode::default(), ptt_active: Arc::new(AtomicBool::new(false)), @@ -197,13 +214,233 @@ impl Default for HuddleState { } impl HuddleState { + /// Begin a new local huddle lifetime and return its identity. + pub(crate) fn begin_huddle_lifetime(&mut self) -> u64 { + self.huddle_generation = self.huddle_generation.wrapping_add(1); + self.huddle_generation + } + + pub(crate) fn owns_huddle_lifetime(&self, huddle_generation: u64, phase: HuddlePhase) -> bool { + self.huddle_generation == huddle_generation && self.phase == phase + } + + /// Whether an async result still belongs to the active huddle that + /// initiated it. The channel id is the huddle-session identity; transcript + /// generation changes within the same huddle must not invalidate it. + pub(crate) fn is_current_huddle( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + ) -> bool { + matches!(self.phase, HuddlePhase::Connected | HuddlePhase::Active) + && self.ephemeral_channel_id.as_deref() == Some(ephemeral_channel_id) + && self.huddle_generation == huddle_generation + } + + /// Whether an STT construction still belongs to the current transcript + /// generation within the active huddle. + pub(crate) fn is_current_transcription_generation( + &self, + ephemeral_channel_id: &str, + huddle_generation: u64, + session_generation: u64, + ) -> bool { + self.is_current_huddle(ephemeral_channel_id, huddle_generation) + && self.session_generation.load(Ordering::Acquire) == session_generation + } + + /// Invalidate in-flight transcription work and give the next constructor a + /// fresh sentinel that stale constructors cannot clear. + pub(crate) fn invalidate_transcription_pipeline(&mut self) { + self.session_generation.fetch_add(1, Ordering::Release); + self.stt_starting = Arc::new(AtomicBool::new(false)); + } + + /// Record an explicit transcription choice made through the existing user + /// control. Later agent membership refreshes must preserve this choice. + pub(crate) fn set_transcription_enabled_by_user(&mut self, enabled: bool) { + self.transcription_enabled = enabled; + self.transcription_user_controlled = true; + } + + /// Enable transcription when an agent is present and the user has not + /// explicitly chosen a transcription state for this huddle. + /// + /// Returns true only for the transition from disabled to enabled, allowing + /// callers to start models/pipelines and emit state exactly once. Removing + /// the last agent deliberately leaves the current state unchanged. + pub(crate) fn maybe_auto_enable_transcription_for_agents(&mut self) -> bool { + let has_agent = !self + .agent_pubkeys + .lock() + .unwrap_or_else(|e| e.into_inner()) + .is_empty(); + if has_agent && !self.transcription_user_controlled && !self.transcription_enabled { + self.transcription_enabled = true; + return true; + } + false + } + /// Reset to default state while preserving the session generation counter. /// Used by start_huddle rollback, join_huddle rollback, and teardown_huddle /// to invalidate in-flight transcription tasks without losing the generation. pub(crate) fn reset_preserving_generation(&mut self) { let gen = Arc::clone(&self.session_generation); + let huddle_generation = self.huddle_generation; *self = Self::default(); self.session_generation = gen; + self.huddle_generation = huddle_generation; + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::Ordering; + + use super::HuddleState; + + fn set_agents(state: &HuddleState, agents: &[&str]) { + *state + .agent_pubkeys + .lock() + .unwrap_or_else(|error| error.into_inner()) = + agents.iter().map(|agent| (*agent).to_owned()).collect(); + } + + #[test] + fn first_agent_auto_enables_transcription_once() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + + assert!(state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + assert!(!state.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn explicit_user_disable_is_not_undone_by_agent_presence() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + state.set_transcription_enabled_by_user(false); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(!state.transcription_enabled); + } + + #[test] + fn last_agent_leaving_preserves_current_transcription_state() { + let mut state = HuddleState::default(); + set_agents(&state, &["agent"]); + assert!(state.maybe_auto_enable_transcription_for_agents()); + + set_agents(&state, &[]); + + assert!(!state.maybe_auto_enable_transcription_for_agents()); + assert!(state.transcription_enabled); + } + + #[test] + fn clone_preserves_user_control_across_frontend_state_reads() { + let mut state = HuddleState::default(); + state.set_transcription_enabled_by_user(false); + + let mut clone = state.clone(); + set_agents(&clone, &["agent"]); + + assert!(clone.transcription_user_controlled); + assert!(!clone.maybe_auto_enable_transcription_for_agents()); + } + + #[test] + fn stale_huddle_identity_is_rejected_after_replacement() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle-a".to_owned()), + ..HuddleState::default() + }; + let huddle_generation = state.begin_huddle_lifetime(); + let generation = state.session_generation.load(Ordering::Acquire); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.session_generation.fetch_add(1, Ordering::Release); + assert!(state.is_current_huddle("huddle-a", huddle_generation)); + assert!(!state.is_current_transcription_generation( + "huddle-a", + huddle_generation, + generation + )); + + state.ephemeral_channel_id = Some("huddle-b".to_owned()); + + assert!(!state.is_current_huddle("huddle-a", huddle_generation)); + } + + #[test] + fn same_channel_rejoin_gets_a_new_huddle_lifetime() { + let mut state = HuddleState { + phase: super::HuddlePhase::Active, + ephemeral_channel_id: Some("huddle".to_owned()), + ..HuddleState::default() + }; + let first_generation = state.begin_huddle_lifetime(); + state.reset_preserving_generation(); + state.phase = super::HuddlePhase::Active; + state.ephemeral_channel_id = Some("huddle".to_owned()); + let second_generation = state.begin_huddle_lifetime(); + + assert_ne!(first_generation, second_generation); + assert!(!state.is_current_huddle("huddle", first_generation)); + assert!(state.is_current_huddle("huddle", second_generation)); + } + + #[test] + fn superseded_create_cannot_commit_or_reset_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + assert!(state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Creating; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Creating)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Creating)); + } + + #[test] + fn superseded_join_cannot_commit_replacement_lifetime() { + let mut state = HuddleState::default(); + let first_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + state.reset_preserving_generation(); + let replacement_generation = state.begin_huddle_lifetime(); + state.phase = super::HuddlePhase::Connecting; + + assert!(!state.owns_huddle_lifetime(first_generation, super::HuddlePhase::Connecting)); + assert!(state.owns_huddle_lifetime(replacement_generation, super::HuddlePhase::Connecting)); + } + + #[test] + fn stale_constructor_cannot_clear_replacement_sentinel() { + let mut state = HuddleState::default(); + let stale_sentinel = std::sync::Arc::clone(&state.stt_starting); + stale_sentinel.store(true, Ordering::Release); + + state.invalidate_transcription_pipeline(); + state.stt_starting.store(true, Ordering::Release); + stale_sentinel.store(false, Ordering::Release); + + assert!(state.stt_starting.load(Ordering::Acquire)); } } diff --git a/desktop/src-tauri/src/huddle/transcription.rs b/desktop/src-tauri/src/huddle/transcription.rs index 0d752c1de7..5962f57cf4 100644 --- a/desktop/src-tauri/src/huddle/transcription.rs +++ b/desktop/src-tauri/src/huddle/transcription.rs @@ -1,5 +1,3 @@ -use std::sync::atomic::Ordering; - use tauri::State; use crate::app_state::AppState; @@ -15,10 +13,12 @@ use super::{models, pipeline::maybe_start_stt_pipeline}; pub async fn start_stt_pipeline(state: State<'_, AppState>) -> Result<(), String> { let ephemeral_channel_id = { let mut hs = state.huddle()?; - hs.transcription_enabled = true; - hs.ephemeral_channel_id + let ephemeral_channel_id = hs + .ephemeral_channel_id .clone() - .ok_or("no active huddle — start or join a huddle first")? + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(true); + ephemeral_channel_id }; match maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { @@ -41,14 +41,17 @@ pub async fn set_huddle_transcription_enabled( ) -> Result<(), String> { let (ephemeral_channel_id, old_stt) = { let mut hs = state.huddle()?; - hs.transcription_enabled = enabled; + let ephemeral_channel_id = hs + .ephemeral_channel_id + .clone() + .ok_or("no active huddle — start or join a huddle first")?; + hs.set_transcription_enabled_by_user(enabled); if enabled { - (hs.ephemeral_channel_id.clone(), None) + (ephemeral_channel_id, None) } else { - hs.session_generation.fetch_add(1, Ordering::Release); - hs.stt_starting.store(false, Ordering::Release); - (hs.ephemeral_channel_id.clone(), hs.stt_pipeline.take()) + hs.invalidate_transcription_pipeline(); + (ephemeral_channel_id, hs.stt_pipeline.take()) } }; @@ -58,12 +61,10 @@ pub async fn set_huddle_transcription_enabled( drop(old_stt); if enabled { - let eph_id = - ephemeral_channel_id.ok_or("no active huddle — start or join a huddle first")?; if let Some(manager) = models::global_model_manager() { manager.start_stt_download(state.http_client.clone()); } - if let Err(e) = maybe_start_stt_pipeline(&state, &eph_id).await { + if let Err(e) = maybe_start_stt_pipeline(&state, &ephemeral_channel_id).await { eprintln!("buzz-desktop: STT transcript start failed: {e}"); } } diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c87617ce9b..7b9bf2b79f 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -11,9 +11,15 @@ import { useManagedAgentsQuery, useRelayAgentsQuery, } from "@/features/agents/hooks"; +import { mergeChannelKnownAgentPubkeys } from "@/features/agents/knownAgentPubkeys"; import { requestOpenCreateAgent } from "@/features/agents/openCreateAgentEvent"; import { useChannelMembersQuery } from "@/features/channels/hooks"; +import { + getDmHuddleMemberPubkeys, + hasOtherDmParticipant, +} from "@/features/channels/lib/dmHuddleMembers"; import { canStartHuddleInChannel } from "@/features/channels/lib/huddleAvailability"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { Channel } from "@/shared/api/types"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { Button } from "@/shared/ui/button"; @@ -64,6 +70,41 @@ export function ChannelMembersBar({ const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); const members = membersQuery.data ?? []; + const dmProfilesQuery = useUsersBatchQuery( + channel.channelType === "dm" ? channel.participantPubkeys : [], + { enabled: channel.channelType === "dm" }, + ); + const huddleAgentPubkeys = React.useMemo(() => { + const pubkeys = new Set( + mergeChannelKnownAgentPubkeys( + membersQuery.data, + managedAgentsQuery.data, + relayAgentsQuery.data, + ), + ); + for (const [pubkey, profile] of Object.entries( + dmProfilesQuery.data?.profiles ?? {}, + )) { + if (profile.isAgent) pubkeys.add(normalizePubkey(pubkey)); + } + return pubkeys; + }, [ + dmProfilesQuery.data?.profiles, + managedAgentsQuery.data, + membersQuery.data, + relayAgentsQuery.data, + ]); + const huddleMemberPubkeys = React.useMemo( + () => getDmHuddleMemberPubkeys(channel, huddleAgentPubkeys, currentPubkey), + [channel, currentPubkey, huddleAgentPubkeys], + ); + const huddleMemberPubkeysPending = + hasOtherDmParticipant(channel, currentPubkey) && + (membersQuery.isPending || + managedAgentsQuery.isPending || + relayAgentsQuery.isPending || + dmProfilesQuery.isPending || + dmProfilesQuery.isPlaceholderData); const memberCount = membersQuery.data?.length ?? channel.memberCount; const providers = React.useMemo( () => @@ -117,7 +158,7 @@ export function ChannelMembersBar({ try { await startHuddle( channel.id, - [], + [...huddleMemberPubkeys], buildHuddleChannelName({ channel, currentPubkey, @@ -133,7 +174,9 @@ export function ChannelMembersBar({ } }} renderMode={variant === "compact" ? "menu-item" : "button"} - startDisabled={!canStartHuddle || isStartingHuddle} + startDisabled={ + !canStartHuddle || isStartingHuddle || huddleMemberPubkeysPending + } /> ); diff --git a/desktop/src/features/huddle/HuddleContext.tsx b/desktop/src/features/huddle/HuddleContext.tsx index 804eb4bc0e..03620838a3 100644 --- a/desktop/src/features/huddle/HuddleContext.tsx +++ b/desktop/src/features/huddle/HuddleContext.tsx @@ -339,6 +339,28 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { [], ); + /** + * Clean up only this provider's media after its start token is superseded. + * The action that changed the token owns backend teardown; issuing a global + * leave here could terminate a replacement huddle started by a new provider. + */ + const cleanupSupersededStart = React.useCallback( + (worklet: AudioWorkletHandle | null) => { + try { + worklet?.stop(); + } catch { + /* best-effort */ + } + workletRef.current = null; + rustActiveRef.current = false; + setLocalAudioTrack(null); + setMicConnected(false); + setEphemeralChannelId(null); + setActiveSpeakers([]); + }, + [], + ); + /** Shared media setup: get mic, setup AudioWorklet, confirm active. * Used by both startHuddle and joinHuddle after the Rust backend call succeeds. */ const connectAndSetupMedia = React.useCallback( @@ -443,7 +465,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { await connectAndSetupMedia(joinInfo, myToken); } catch (e) { if (e instanceof Error && e.message === "superseded") { - await cleanupFailedStart(workletRef.current, true); + cleanupSupersededStart(workletRef.current); return; } throw e; @@ -466,7 +488,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { busyRef.current = false; } }, - [cleanupFailedStart, connectAndSetupMedia], + [cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia], ); const joinHuddle = React.useCallback( @@ -489,7 +511,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { await connectAndSetupMedia(joinInfo, myToken); } catch (e) { if (e instanceof Error && e.message === "superseded") { - await cleanupFailedStart(workletRef.current, false); + cleanupSupersededStart(workletRef.current); return; } throw e; @@ -512,7 +534,7 @@ export function HuddleProvider({ children }: { children: React.ReactNode }) { busyRef.current = false; } }, - [cleanupFailedStart, connectAndSetupMedia], + [cleanupFailedStart, cleanupSupersededStart, connectAndSetupMedia], ); useTtsSubscription(ephemeralChannelId, selfPubkeyRef); diff --git a/desktop/src/features/huddle/components/HuddleBar.tsx b/desktop/src/features/huddle/components/HuddleBar.tsx index 35d660e475..758726cf58 100644 --- a/desktop/src/features/huddle/components/HuddleBar.tsx +++ b/desktop/src/features/huddle/components/HuddleBar.tsx @@ -242,7 +242,10 @@ export function HuddleBar({ // Primary: listen for Rust-emitted state change events listen("huddle-state-changed", (event) => { - if (!cancelled) applyIncomingState(event.payload); + if (!cancelled) { + stateGenerationRef.current += 1; + applyIncomingState(event.payload); + } }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -757,14 +760,11 @@ export function HuddleBar({ transcriptionEnabled ? "Stop transcript" : "Start transcript" } aria-pressed={transcriptionEnabled} - className={cn( - "buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md", - transcriptionEnabled && "text-foreground", - )} + className="buzz-huddle-control-button h-12 w-12 shrink-0 rounded-md" onClick={() => void handleToggleTranscript()} size="icon" type="button" - variant={transcriptionEnabled ? "secondary" : "ghost"} + variant="ghost" > diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index f2739088a6..da51ab1b88 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -260,11 +260,18 @@ export function UserProfilePopover({ const selfProfileQuery = useProfileQuery(open && showProfileActions); const isCurrentUserOwner = ownsAuthorAgent(profile, currentPubkey); const viewerIsOwner = isCurrentUserOwner || isOwner === true; + const showHuddleAction = + showHumanProfileActions || + (showProfileActions && + isBotProfile && + viewerIsOwner && + !isAgentClassificationPending); const showMessageAction = showProfileActions && !isAgentClassificationPending && (!isBotProfile || viewerIsOwner); - const showAnyProfileActions = showHumanProfileActions || showMessageAction; + const showAnyProfileActions = + showHumanProfileActions || showMessageAction || showHuddleAction; const canViewActivity = isBotProfile && viewerIsOwner && canOpenAgentActivity(pubkey); const presenceStatus = presenceQuery.data?.[pubkey.toLowerCase()]; @@ -356,7 +363,7 @@ export function UserProfilePopover({ const handleHuddle = React.useCallback(async () => { if ( !showProfileActions || - !showHumanProfileActions || + !showHuddleAction || pendingAction !== null || isStartingHuddle ) { @@ -369,7 +376,7 @@ export function UserProfilePopover({ try { const dm = await openDmMutation.mutateAsync({ pubkeys: [pubkey] }); await goChannel(dm.id); - await startHuddle(dm.id, []); + await startHuddle(dm.id, isBotProfile ? [pubkey] : []); await queryClient.invalidateQueries({ queryKey: channelsQueryKey }); if (isMountedRef.current) { setOpen(false); @@ -389,7 +396,8 @@ export function UserProfilePopover({ pendingAction, pubkey, queryClient, - showHumanProfileActions, + isBotProfile, + showHuddleAction, showProfileActions, startHuddle, ]); @@ -722,7 +730,7 @@ export function UserProfilePopover({ Message ) : null} - {showHumanProfileActions ? ( + {showHuddleAction ? ( + + + { + if (settings) void savePocketVoice(voiceKey); + }} + value={selectedVoice?.key} + > + {voices.map((voice) => ( + + {voiceOptionLabel(voice, voices)} + + ))} + + + + + + + + + + {error && ( +

+ {error} +

+ )} + + + ); +} diff --git a/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs b/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs new file mode 100644 index 0000000000..8ffc918471 --- /dev/null +++ b/desktop/src/features/settings/ui/voiceSettingsLogic.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + selectedVoiceForBackend, + voiceOptionLabel, + voicesForBackend, +} from "./voiceSettingsLogic.ts"; + +const voice = (key, displayName, fallbackKey = "pocket:mary") => ({ + key, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey, + referenceFile: `${key}.wav`, + provenance: { + source: "bundled", + contentHash: null, + license: null, + sourceUrl: null, + }, +}); + +test("Pocket-only V1 filters the shared registry by backend", () => { + const registry = [ + voice("pocket:mary", "Mary", null), + { ...voice("siri:aaron", "Aaron"), backend: "siri" }, + ]; + assert.deepEqual( + voicesForBackend(registry, "pocket").map((entry) => entry.key), + ["pocket:mary"], + ); +}); + +test("local selection uses the first compatible qualified preference", () => { + const voices = [ + voice("pocket:mary", "Mary", null), + voice("pocket:eve", "Eve"), + ]; + assert.equal( + selectedVoiceForBackend(["siri:aaron", "pocket:eve", "pocket:mary"], voices) + ?.key, + "pocket:eve", + ); +}); + +test("duplicate display labels remain distinct by content-derived key", () => { + const voices = [ + voice("pocket:imported:aaa", "Jim"), + voice("pocket:imported:bbb", "Jim"), + ]; + assert.equal( + selectedVoiceForBackend(["pocket:imported:bbb"], voices)?.key, + "pocket:imported:bbb", + ); + assert.equal(voiceOptionLabel(voices[0], voices), "Jim · aaa"); + assert.equal(voiceOptionLabel(voices[1], voices), "Jim · bbb"); +}); diff --git a/desktop/src/features/settings/ui/voiceSettingsLogic.ts b/desktop/src/features/settings/ui/voiceSettingsLogic.ts new file mode 100644 index 0000000000..30352f2d0f --- /dev/null +++ b/desktop/src/features/settings/ui/voiceSettingsLogic.ts @@ -0,0 +1,57 @@ +export type VoiceAvailability = + | "bundled" + | "installed" + | "downloadable" + | "unavailable"; + +export type VoiceRegistryEntry = { + key: string; + displayName: string; + backend: string; + backendName: string; + availability: VoiceAvailability; + fallbackKey: string | null; + referenceFile: string | null; + provenance: { + source: string; + contentHash: string | null; + license: string | null; + sourceUrl: string | null; + }; +}; + +export function voicesForBackend( + registry: readonly VoiceRegistryEntry[], + backend: string, +): VoiceRegistryEntry[] { + return registry.filter( + (voice) => + voice.backend === backend && + (voice.availability === "bundled" || voice.availability === "installed"), + ); +} + +export function selectedVoiceForBackend( + preferences: readonly string[], + voices: readonly VoiceRegistryEntry[], +): VoiceRegistryEntry | undefined { + for (const key of preferences) { + const voice = voices.find((candidate) => candidate.key === key); + if (voice) return voice; + } + return voices.find((voice) => voice.fallbackKey === null) ?? voices[0]; +} + +export function voiceOptionLabel( + voice: VoiceRegistryEntry, + voices: readonly VoiceRegistryEntry[], +): string { + const duplicateLabel = voices.some( + (candidate) => + candidate.key !== voice.key && + candidate.displayName === voice.displayName, + ); + if (!duplicateLabel) return voice.displayName; + const identitySuffix = voice.key.split(":").at(-1)?.slice(-8) ?? voice.key; + return `${voice.displayName} · ${identitySuffix}`; +} diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts index c7446f4e70..121b00c800 100644 --- a/desktop/src/shared/api/readOnlyRelayClient.ts +++ b/desktop/src/shared/api/readOnlyRelayClient.ts @@ -12,7 +12,7 @@ import { AUTH_TIMEOUT_MS, HISTORY_TIMEOUT_MS, PUBLISH_TIMEOUT_MS, -} from "@/shared/api/relayClientSession"; +} from "@/shared/api/relayClientTimings"; type PendingHistory = { events: RelayEvent[]; diff --git a/desktop/src/shared/api/relayChannelFilters.test.mjs b/desktop/src/shared/api/relayChannelFilters.test.mjs index 3519c82146..503f1a5d58 100644 --- a/desktop/src/shared/api/relayChannelFilters.test.mjs +++ b/desktop/src/shared/api/relayChannelFilters.test.mjs @@ -6,6 +6,7 @@ import { buildChannelAuxFilter, buildChannelReactionAuxFilter, buildChannelStructuralAuxFilter, + buildHuddleTtsLiveFilter, } from "./relayChannelFilters.ts"; const CHANNEL = "36411e44-0e2d-4cfe-bd6e-567eb169db9f"; @@ -14,6 +15,14 @@ const IDS = [ "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", ]; +test("huddle TTS filter is future-only for both message kinds", () => { + assert.deepEqual(buildHuddleTtsLiveFilter(CHANNEL), { + kinds: [9, 40002], + "#h": [CHANNEL], + limit: 0, + }); +}); + // Regression: reaction (kind:7) and reaction-removal (kind:5) events carry only // an `e` tag, no channel `h` tag. An `#h`-scoped aux query never matches them, // so removed historical reactions reappear. The aux filters must key on `#e` diff --git a/desktop/src/shared/api/relayChannelFilters.ts b/desktop/src/shared/api/relayChannelFilters.ts index d0c7e7938e..0d432d422e 100644 --- a/desktop/src/shared/api/relayChannelFilters.ts +++ b/desktop/src/shared/api/relayChannelFilters.ts @@ -6,6 +6,8 @@ import { KIND_DELETION, KIND_NIP29_DELETE_EVENT, KIND_REACTION, + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, } from "@/shared/constants/kinds"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; @@ -40,6 +42,17 @@ export function buildChannelFilter( return filter; } +/** Strictly live huddle message filter: zero stored rows, future messages only. */ +export function buildHuddleTtsLiveFilter( + channelId: string, +): RelaySubscriptionFilter { + return { + kinds: [KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2], + "#h": [channelId], + limit: 0, + }; +} + /** * History filter for cold-load and scrollback: message kinds *only*, so the * `limit` budget buys visible message depth. Auxiliary events (reactions, diff --git a/desktop/src/shared/api/relayClientSession.ts b/desktop/src/shared/api/relayClientSession.ts index 94438386eb..53d541ff0f 100644 --- a/desktop/src/shared/api/relayClientSession.ts +++ b/desktop/src/shared/api/relayClientSession.ts @@ -53,20 +53,19 @@ import { } from "@/shared/api/relayReconnectPolicy"; import { RelayReconnectWaiters } from "@/shared/api/relayReconnectWaiters"; import { RelayStallWatchdog } from "@/shared/api/relayStallWatchdog"; +import { + AUTH_TIMEOUT_MS, + BACKOFF_RESET_STABLE_MS, + EVENT_BATCH_MS, + HISTORY_TIMEOUT_MS, + PUBLISH_TIMEOUT_MS, + RECONNECT_BASE_DELAY_MS, + RECONNECT_MAX_DELAY_MS, + STALL_CHECK_INTERVAL_MS, + STALL_IDLE_TIMEOUT_MS, +} from "@/shared/api/relayClientTimings"; import { closeWebSocket } from "@/shared/api/relayWebSocketClose"; import { buildThreadReferenceTags } from "@/features/messages/lib/threading"; -const RECONNECT_BASE_DELAY_MS = 1_000, - RECONNECT_MAX_DELAY_MS = 30_000, - EVENT_BATCH_MS = 16; - -export const AUTH_TIMEOUT_MS = 25_000; -export const HISTORY_TIMEOUT_MS = 25_000; -export const PUBLISH_TIMEOUT_MS = 25_000; - -export const BACKOFF_RESET_STABLE_MS = 60_000; - -const STALL_CHECK_INTERVAL_MS = 10_000; -const STALL_IDLE_TIMEOUT_MS = 60_000; export class RelayClient { private wsId: number | null = null; diff --git a/desktop/src/shared/api/relayClientTimings.ts b/desktop/src/shared/api/relayClientTimings.ts new file mode 100644 index 0000000000..dbe85a835c --- /dev/null +++ b/desktop/src/shared/api/relayClientTimings.ts @@ -0,0 +1,20 @@ +export const RECONNECT_BASE_DELAY_MS = 1_000; +export const RECONNECT_MAX_DELAY_MS = 30_000; +export const EVENT_BATCH_MS = 16; + +/** + * Op-level timeouts tolerate degraded networks where TLS handshakes and DNS + * resolution can take several seconds. + */ +export const AUTH_TIMEOUT_MS = 25_000; +export const HISTORY_TIMEOUT_MS = 25_000; +export const PUBLISH_TIMEOUT_MS = 25_000; + +/** + * A stability-gated reset prevents reconnect flapping from erasing backoff. + */ +export const BACKOFF_RESET_STABLE_MS = 60_000; + +/** Passive liveness thresholds for the relay heartbeat stream. */ +export const STALL_CHECK_INTERVAL_MS = 10_000; +export const STALL_IDLE_TIMEOUT_MS = 60_000; diff --git a/desktop/src/shared/api/relayReconnectReplay.test.mjs b/desktop/src/shared/api/relayReconnectReplay.test.mjs index 54254e89de..59253a459f 100644 --- a/desktop/src/shared/api/relayReconnectReplay.test.mjs +++ b/desktop/src/shared/api/relayReconnectReplay.test.mjs @@ -5,6 +5,7 @@ import { buildReconnectReplayFilter, replayLiveSubscriptions, REPLAY_BATCH_SIZE, + shouldPageReconnectReplay, } from "./relayReconnectReplay.ts"; import { buildChannelFilter } from "./relayChannelFilters.ts"; @@ -113,6 +114,31 @@ test("reconnect replay caps large steady-state limits", () => { }); }); +test("reconnect replay preserves the live-only zero-history contract", () => { + const filter = { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + }; + + assert.deepEqual(replayFilter(filter, 123), { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + since: 123, + }); +}); + +test("live-only subscriptions do not page reconnect history", () => { + const filter = { + kinds: [9], + "#h": ["channel-1"], + limit: 0, + }; + + assert.equal(shouldPageReconnectReplay(filter), false); +}); + test("reconnect replay keeps the stricter existing since window", () => { const filter = { kinds: [9], diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index dcad430bbf..97051b68ec 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -161,6 +161,11 @@ type MockHuddleSeed = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { + ttsSettings?: { + version: number; + agentTextToSpeech: boolean; + voicePreferences: string[]; + }; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -9954,6 +9959,161 @@ export function maybeInstallE2eTauriMocks() { } case "get_model_status": return { stt: "ready", tts: "ready" }; + case "get_tts_settings": + return ( + activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + } + ); + case "list_voice_registry": + return [ + [ + "anna", + "Anna", + "anna.wav", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", + ], + [ + "vera", + "Vera", + "vera.wav", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", + ], + [ + "fantine", + "Fantine", + "fantine.wav", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", + ], + [ + "charles", + "Charles", + "charles.wav", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", + ], + [ + "paul", + "Paul", + "paul.wav", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", + ], + [ + "eponine", + "Eponine", + "eponine.wav", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", + ], + [ + "azelma", + "Azelma", + "azelma.wav", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", + ], + [ + "george", + "George", + "george.wav", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", + ], + [ + "mary", + "Mary", + "reference_sample.wav", + "p333_023_enhanced.wav", + "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + ], + [ + "jane", + "Jane", + "jane.wav", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", + ], + [ + "michael", + "Michael", + "michael.wav", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", + ], + [ + "eve", + "Eve", + "eve.wav", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", + ], + ].map( + ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ + key: `pocket:${id}`, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey: id === "mary" ? null : "pocket:mary", + referenceFile, + provenance: { + source: "bundled", + contentHash, + license: "CC-BY-4.0", + sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, + }, + }), + ); + case "set_tts_enabled": { + const enabled = (payload as { enabled?: boolean })?.enabled; + if (typeof enabled !== "boolean") + throw new Error("Missing text-to-speech enabled state"); + const settings = { + version: 1, + agentTextToSpeech: enabled, + voicePreferences: activeConfig?.mock?.ttsSettings + ?.voicePreferences ?? ["pocket:mary"], + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return settings; + } + case "set_pocket_voice": { + const voiceKey = (payload as { voiceKey?: string })?.voiceKey; + if (!voiceKey) throw new Error("Missing Pocket voice key"); + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const firstPocketIndex = current.voicePreferences.findIndex((key) => + key.startsWith("pocket:"), + ); + const preferences = current.voicePreferences.filter( + (key) => !key.startsWith("pocket:"), + ); + preferences.splice( + firstPocketIndex < 0 ? preferences.length : firstPocketIndex, + 0, + voiceKey, + ); + const settings = { ...current, voicePreferences: preferences }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return settings; + } + case "preview_pocket_voice": + return null; case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts new file mode 100644 index 0000000000..490bee3424 --- /dev/null +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -0,0 +1,119 @@ +import { expect, test } from "@playwright/test"; + +import { waitForAnimations } from "../helpers/animations"; +import { installMockBridge } from "../helpers/bridge"; +import { openSettings } from "../helpers/settings"; + +const SCREENSHOT_PATH = "test-results/voice-settings/pocket-voices.png"; + +test.describe("Pocket voice settings", () => { + test.use({ viewport: { width: 1100, height: 760 } }); + + test("selects and retains a bundled voice while text to speech is off", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + const card = page.getByTestId("settings-voice"); + await expect(card).toBeVisible(); + await expect( + page.getByText("Agent text to speech", { exact: true }), + ).toBeVisible(); + await expect( + page.getByText("Pocket TTS voice", { exact: true }), + ).toBeVisible(); + await expect(card).not.toContainText("April INT8"); + + await page.getByTestId("pocket-voice-selector").click(); + await expect(page.getByRole("menuitemradio")).toHaveCount(12); + await page.getByRole("menuitemradio", { name: "Eve" }).click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + await expect( + page.getByRole("button", { name: "Pocket TTS voice: Eve" }), + ).toBeVisible(); + + await page.getByTestId("agent-text-to-speech-toggle").click(); + await expect( + page.getByTestId("agent-text-to-speech-toggle"), + ).toHaveAttribute("aria-checked", "false"); + await expect(page.getByTestId("pocket-voice-controls")).toHaveAttribute( + "aria-disabled", + "true", + ); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + + const savedCommands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => + ["set_pocket_voice", "set_tts_enabled"].includes(entry.command), + ) + .map((entry) => ({ command: entry.command, payload: entry.payload })), + ); + expect(savedCommands).toEqual([ + { + command: "set_pocket_voice", + payload: { voiceKey: "pocket:eve" }, + }, + { + command: "set_tts_enabled", + payload: { enabled: false }, + }, + ]); + }); + + test("captures the complete VCTK preset settings surface", async ({ + page, + }) => { + await installMockBridge(page, { + ttsSettings: { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:eve"], + }, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + const card = page.getByTestId("settings-voice"); + await expect(card).toBeVisible(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Eve", + ); + await page.getByTestId("pocket-voice-selector").click(); + await expect(page.getByRole("menuitemradio")).toHaveCount(12); + const menu = page.getByRole("menu"); + await expect(menu).toBeVisible(); + await waitForAnimations(page); + const cardBox = await card.boundingBox(); + const menuBox = await menu.boundingBox(); + const viewport = page.viewportSize(); + if (!cardBox || !menuBox || !viewport) { + throw new Error("Voice settings screenshot bounds are unavailable"); + } + const x = Math.max(0, Math.min(cardBox.x, menuBox.x) - 16); + const y = Math.max(0, Math.min(cardBox.y, menuBox.y) - 16); + const right = Math.min( + viewport.width, + Math.max(cardBox.x + cardBox.width, menuBox.x + menuBox.width) + 16, + ); + const bottom = Math.min( + viewport.height, + Math.max(cardBox.y + cardBox.height, menuBox.y + menuBox.height) + 16, + ); + await page.screenshot({ + path: SCREENSHOT_PATH, + clip: { + x: Math.floor(x), + y: Math.floor(y), + width: Math.ceil(right - x), + height: Math.ceil(bottom - y), + }, + }); + }); +}); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 49d12ec17a..2d41d27a5e 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -159,6 +159,11 @@ type MockInstallRuntimeResult = { }; type MockBridgeOptions = { + ttsSettings?: { + version: number; + agentTextToSpeech: boolean; + voicePreferences: string[]; + }; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ diff --git a/desktop/tests/helpers/settings.ts b/desktop/tests/helpers/settings.ts index a63b52453e..c26c0e9195 100644 --- a/desktop/tests/helpers/settings.ts +++ b/desktop/tests/helpers/settings.ts @@ -3,6 +3,7 @@ import { expect, type Page } from "@playwright/test"; type SettingsSection = | "profile" | "notifications" + | "voice" | "agents" | "channel-templates" | "compute" diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 0a3b2ed5a2..2e7e944482 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -84,6 +84,9 @@ run_unit_tests() { run_test_step "buzz-auth unit tests" \ cargo test -p buzz-auth --lib -- --nocapture + run_test_step "buzz-voice tests" \ + cargo test -p buzz-voice --lib -- --nocapture + run_test_step "buzz-cli tests" \ cargo test -p buzz-cli -- --nocapture From 39ce3dfc3cf2d12f0d6c64b4cd4293df86567663 Mon Sep 17 00:00:00 2001 From: klopez4212 Date: Fri, 31 Jul 2026 15:24:51 +0100 Subject: [PATCH 38/87] fix(desktop): open profiles from avatars (#3751) ## Summary - show profile descriptions in hover cards as a single truncated line - open the profile panel when avatars are clicked across desktop surfaces - make the direct-message intro avatar clickable ## Validation - Desktop static checks - 3,807 desktop tests via pre-push --------- Signed-off-by: kenny lopez Signed-off-by: Wes Co-authored-by: Wes Co-authored-by: Carl --- desktop/scripts/check-pubkey-truncation.mjs | 6 +- desktop/src/app/AppHuddleBar.tsx | 25 ++ desktop/src/app/AppProfilePanelProvider.tsx | 22 + desktop/src/app/AppShell.tsx | 412 +++++++++--------- .../src/app/navigation/useAppNavigation.ts | 13 + .../channels/ui/ChannelScreenHeader.tsx | 64 ++- .../ui/CommunityMembersSettingsCard.tsx | 17 +- .../huddle/components/ParticipantList.tsx | 33 +- .../ui/DirectMessageIntroAvatarStack.tsx | 44 +- .../e2e/invites-settings-screenshots.spec.ts | 11 +- 10 files changed, 380 insertions(+), 267 deletions(-) create mode 100644 desktop/src/app/AppHuddleBar.tsx create mode 100644 desktop/src/app/AppProfilePanelProvider.tsx diff --git a/desktop/scripts/check-pubkey-truncation.mjs b/desktop/scripts/check-pubkey-truncation.mjs index 95e56fb282..d65db13545 100644 --- a/desktop/scripts/check-pubkey-truncation.mjs +++ b/desktop/scripts/check-pubkey-truncation.mjs @@ -18,12 +18,10 @@ const rules = [ // Non-display uses: array windows over pubkey lists, color/initials // derivation where the value is never presented as an identity. const overrides = new Set([ - // ProfileAvatar fallback label — decorative glyphs inside an avatar disc. - "src/features/huddle/components/ParticipantList.tsx:92", // HexAvatar: 6-char badge + hue derivation inside a color-coded disc, // clearly decorative (paired with a full truncatePubkey aria-label). - "src/features/huddle/components/ParticipantList.tsx:143", - "src/features/huddle/components/ParticipantList.tsx:144", + "src/features/huddle/components/ParticipantList.tsx:150", + "src/features/huddle/components/ParticipantList.tsx:151", // clientId (not a pubkey) sliced in a debug log next to the real thing. "src/features/channels/readState/readStateManager.ts:338", // Array windows (first N pubkeys), not string truncation. diff --git a/desktop/src/app/AppHuddleBar.tsx b/desktop/src/app/AppHuddleBar.tsx new file mode 100644 index 0000000000..9fa12d513f --- /dev/null +++ b/desktop/src/app/AppHuddleBar.tsx @@ -0,0 +1,25 @@ +import type * as React from "react"; + +import { HuddleBar } from "@/features/huddle"; + +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; + +type AppHuddleBarProps = Pick< + React.ComponentProps, + "onOpenThread" | "onVisibilityChange" +>; + +export function AppHuddleBar({ + onOpenThread, + onVisibilityChange, +}: AppHuddleBarProps) { + return ( + + + + ); +} diff --git a/desktop/src/app/AppProfilePanelProvider.tsx b/desktop/src/app/AppProfilePanelProvider.tsx new file mode 100644 index 0000000000..213acec498 --- /dev/null +++ b/desktop/src/app/AppProfilePanelProvider.tsx @@ -0,0 +1,22 @@ +import * as React from "react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { ProfilePanelProvider } from "@/shared/context/ProfilePanelContext"; + +export function AppProfilePanelProvider({ + children, +}: Readonly<{ children: React.ReactNode }>) { + const { goProfile } = useAppNavigation(); + const handleOpenProfilePanel = React.useCallback( + (pubkey: string) => { + void goProfile(pubkey); + }, + [goProfile], + ); + + return ( + + {children} + + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b856434e61..4eb0a42bbe 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -63,7 +63,8 @@ import { type SettingsSection, isSettingsSection, } from "@/features/settings/ui/SettingsPanels"; -import { HuddleBar, HuddleProvider } from "@/features/huddle"; +import { HuddleProvider } from "@/features/huddle"; +import { AppHuddleBar } from "@/app/AppHuddleBar"; import { useDueReminderBadgeCount } from "@/features/reminders/hooks"; import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider"; import { useReminderNotifications } from "@/features/reminders/useReminderNotifications"; @@ -97,7 +98,7 @@ import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay"; import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard"; import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu"; - +import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider"; const LazySettingsScreen = React.lazy(async () => { const module = await import("@/features/settings/ui/SettingsScreen"); return { default: module.SettingsScreen }; @@ -160,7 +161,6 @@ export function AppShell() { ? locationSearchSection : DEFAULT_SETTINGS_SECTION; const startupReady = useDeferredStartup(); - const identityQuery = useIdentityQuery(); const { mutedChannelIds, muteChannel, unmuteChannel } = useChannelMutes( identityQuery.data?.pubkey, @@ -303,7 +303,6 @@ export function AppShell() { ? (channels.find((channel) => channel.id === targetChannelId) ?? null) : null; }, [channels, managedChannelId, selectedChannelId]); - const { handleChannelNotification, handleDmNotification, @@ -518,7 +517,6 @@ export function AppShell() { }, [applyAgents, applyCanvas, createChannelMutation, goChannel], ); - const handleCreateForum = React.useCallback( async ({ description, @@ -586,7 +584,6 @@ export function AppShell() { }, [goHome, hideDmMutation, selectedChannelId], ); - const handleOpenSettings = React.useCallback( (section: SettingsSection = DEFAULT_SETTINGS_SECTION) => { setIsChannelManagementOpen(false); @@ -594,12 +591,10 @@ export function AppShell() { }, [goSettings], ); - const handleCloseSettings = React.useCallback( () => closeSettings(), [closeSettings], ); - // Section switches rewrite the settings entry rather than stacking one // history entry per section, so back always exits settings in one step. const handleSettingsSectionChange = React.useCallback( @@ -620,11 +615,8 @@ export function AppShell() { unreadChannelIds, unreadChannelNotificationCount, }); + // Dispatch `buzz://message` deep links into the router. useMessageDeepLinks(); - const handleOpenNewDm = React.useCallback( - () => void goNewMessage(), - [goNewMessage], - ); const handleOpenCreateChannel = React.useCallback( () => setIsCreateChannelOpen(true), [], @@ -657,7 +649,7 @@ export function AppShell() { if (key === "k" && event.shiftKey) { event.preventDefault(); - handleOpenNewDm(); + void goNewMessage(); return; } @@ -686,9 +678,9 @@ export function AppShell() { }; }, [ handleOpenBrowseChannels, - handleOpenNewDm, handleOpenCreateChannel, handleOpenSearch, + goNewMessage, goHome, settingsOpen, ]); @@ -770,216 +762,224 @@ export function AppShell() { /> ) : null} - {!settingsOpen ? ( - - ) : null} - {settingsOpen ? ( -
- - + {!settingsOpen ? ( + + ) : null} + {settingsOpen ? ( +
+ + + +
+ ) : ( +
+ { + const id = communitiesHook.addCommunity({ + ...community, + pubkey: + community.pubkey ?? + identityQuery.data?.pubkey, + }); + handleSwitchCommunity(id); + }} + onAddCommunityOpenChange={ + addCommunityDialog.onOpenChange } - notificationSettings={notificationSettings.settings} - onClose={handleCloseSettings} - onSectionChange={handleSettingsSectionChange} - onSetDesktopNotificationsEnabled={ - notificationSettings.setDesktopEnabled + onNewMessage={goNewMessage} + onBackgroundClick={requestFocusedThreadClose} + onCreateChannelOpenChange={setIsCreateChannelOpen} + onOpenAddCommunity={addCommunityDialog.openDialog} + onSendFeedback={() => setIsSendFeedbackOpen(true)} + onUpdateCommunity={communitiesHook.updateCommunity} + onRemoveCommunity={(id) => + void handleRemoveCommunity(id) } - onSetHomeBadgeEnabled={ - notificationSettings.setHomeBadgeEnabled + onSwitchCommunity={handleSwitchCommunity} + onCreateAgent={() => requestOpenCreateAgent()} + selfPresenceStatus={presenceSession.currentStatus} + communities={communitiesHook.communities} + onCreateChannel={handleCreateChannel} + onCreateForum={handleCreateForum} + onHideDm={handleHideDm} + onMarkAllChannelsRead={markAllChannelsRead} + onMarkChannelRead={markChannelRead} + onMarkChannelUnread={markChannelUnread} + onBrowseChannels={handleOpenBrowseChannels} + onOpenDm={async ({ pubkeys }) => { + const directMessage = + await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onSelectAgents={() => void goAgents()} + onSelectChannel={(channelId) => + void goChannel(channelId) } - onSetSlotAlertsEnabled={ - notificationSettings.setSlotAlertsEnabled + onOpenSearchResult={handleOpenSearchResult} + searchChannels={channels} + searchFocusRequest={searchFocusRequest} + onSelectHome={() => void goHome()} + onSelectProjects={() => void goProjects()} + onSelectPulse={() => void goPulse()} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => void goWorkflows()} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) } - onSetNotifyWhileViewing={ - notificationSettings.setNotifyWhileViewing + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) } - onSetAllSlotAlertsEnabled={ - notificationSettings.setAllSlotAlertsEnabled + onClearUserStatus={() => + setUserStatusMutation.mutate({ + text: "", + emoji: "", + }) } - onSetSoundForSlot={ - notificationSettings.setSoundForSlot + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[ + deferredPubkey.toLowerCase() + ] ?? undefined) + : undefined } - section={settingsSection} + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + unreadChannelCounts={unreadChannelCounts} + mutedChannelIds={mutedChannelIds} + onMuteChannel={muteChannel} + onUnmuteChannel={unmuteChannel} + starredChannelIds={starredChannelIds} + onStarChannel={starChannel} + onUnstarChannel={unstarChannel} /> - -
- ) : ( -
- { - const id = communitiesHook.addCommunity({ - ...community, - pubkey: - community.pubkey ?? identityQuery.data?.pubkey, - }); - handleSwitchCommunity(id); - }} - onAddCommunityOpenChange={ - addCommunityDialog.onOpenChange - } - onNewMessage={handleOpenNewDm} - onBackgroundClick={requestFocusedThreadClose} - onCreateChannelOpenChange={setIsCreateChannelOpen} - onOpenAddCommunity={addCommunityDialog.openDialog} - onSendFeedback={() => setIsSendFeedbackOpen(true)} - onUpdateCommunity={communitiesHook.updateCommunity} - onRemoveCommunity={(id) => - void handleRemoveCommunity(id) - } - onSwitchCommunity={handleSwitchCommunity} - onCreateAgent={() => requestOpenCreateAgent()} - selfPresenceStatus={presenceSession.currentStatus} - communities={communitiesHook.communities} - onCreateChannel={handleCreateChannel} - onCreateForum={handleCreateForum} - onHideDm={handleHideDm} - onMarkAllChannelsRead={markAllChannelsRead} - onMarkChannelRead={markChannelRead} - onMarkChannelUnread={markChannelUnread} - onBrowseChannels={handleOpenBrowseChannels} - onOpenDm={async ({ pubkeys }) => { - const directMessage = - await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onSelectAgents={() => void goAgents()} - onSelectChannel={(channelId) => - void goChannel(channelId) - } - onOpenSearchResult={handleOpenSearchResult} - searchChannels={channels} - searchFocusRequest={searchFocusRequest} - onSelectHome={() => void goHome()} - onSelectProjects={() => void goProjects()} - onSelectPulse={() => void goPulse()} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => void goWorkflows()} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - onSetUserStatus={(text, emoji) => - setUserStatusMutation.mutate({ text, emoji }) - } - onClearUserStatus={() => - setUserStatusMutation.mutate({ - text: "", - emoji: "", - }) - } - profile={profileQuery.data} - selfUserStatus={ - deferredPubkey - ? (selfStatusQuery.data?.[ - deferredPubkey.toLowerCase() - ] ?? undefined) - : undefined + + + + + + + + +
+ )} + + + { + setIsChannelManagementOpen(open); + if (!open) { + setManagedChannelId(null); } - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - unreadChannelCounts={unreadChannelCounts} - mutedChannelIds={mutedChannelIds} - onMuteChannel={muteChannel} - onUnmuteChannel={unmuteChannel} - starredChannelIds={starredChannelIds} - onStarChannel={starChannel} - onUnstarChannel={unstarChannel} - /> - - - - - - - - -
- )} - - - { - setIsChannelManagementOpen(open); - if (!open) { + }} + onDeleteActiveChannel={() => { + setIsChannelManagementOpen(false); setManagedChannelId(null); - } - }} - onDeleteActiveChannel={() => { - setIsChannelManagementOpen(false); - setManagedChannelId(null); - void goHome({ replace: true }); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - + void goHome({ replace: true }); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> + +
- { void goChannel(channelId, { messageId, diff --git a/desktop/src/app/navigation/useAppNavigation.ts b/desktop/src/app/navigation/useAppNavigation.ts index f928970610..d19ac03120 100644 --- a/desktop/src/app/navigation/useAppNavigation.ts +++ b/desktop/src/app/navigation/useAppNavigation.ts @@ -79,6 +79,18 @@ export function useAppNavigation() { [commitNavigation], ); + const goProfile = React.useCallback( + (pubkey: string, behavior?: NavigationBehavior) => + commitNavigation( + { + to: "/pulse", + search: { profile: pubkey }, + }, + behavior, + ), + [commitNavigation], + ); + const goProjects = React.useCallback( (behavior?: NavigationBehavior) => commitNavigation( @@ -303,6 +315,7 @@ export function useAppNavigation() { goProject, goProjects, goPulse, + goProfile, goSettings, goWorkflow, goWorkflows, diff --git a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx index a3a8a20231..4c545baf68 100644 --- a/desktop/src/features/channels/ui/ChannelScreenHeader.tsx +++ b/desktop/src/features/channels/ui/ChannelScreenHeader.tsx @@ -13,6 +13,7 @@ import { ProfileAvatarWithStatus, scaleProfileAvatarStatusGeometry, } from "@/features/profile/ui/ProfileAvatarWithStatus"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { Button } from "@/shared/ui/button"; import type { Channel, PresenceStatus } from "@/shared/api/types"; import { UserAvatar } from "@/shared/ui/UserAvatar"; @@ -65,6 +66,7 @@ export function ChannelScreenHeader({ const isGroupDm = activeChannel?.channelType === "dm" && activeDmHeaderParticipants.length > 1; + const activeDmParticipant = activeDmHeaderParticipants[0] ?? null; const showJoinButton = activeChannel !== null && !activeChannel.isMember && @@ -113,6 +115,25 @@ export function ChannelScreenHeader({ + ) : activeDmParticipant ? ( + + + ) : (
- + + +
- {profile?.displayName || profile?.avatarUrl ? ( - - ) : ( - - )} + + {profile?.displayName || profile?.avatarUrl ? ( + + ) : ( + + )} +
diff --git a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx index 1a1018fbe7..e2922d80b7 100644 --- a/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx +++ b/desktop/src/features/messages/ui/DirectMessageIntroAvatarStack.tsx @@ -1,4 +1,5 @@ import { getDmParticipantPreview } from "@/features/channels/lib/dmParticipantDisplay"; +import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; import { UserAvatar } from "@/shared/ui/UserAvatar"; export type DirectMessageIntroParticipant = { @@ -18,31 +19,36 @@ export function DirectMessageIntroAvatarStack({ return ( + { + if (!open) setDeleteCandidate(null); + }} + open={deleteCandidate !== null} + > + + + Delete imported voice? + + {deleteCandidate + ? `${deleteCandidate.displayName} and its local audio file will be removed.` + : "This imported voice and its local audio file will be removed."} + {selectedVoice?.key === deleteCandidate?.key && + " Mary will be selected instead."} + + + + Cancel + { + event.preventDefault(); + if (deleteCandidate) { + void deletePocketVoice(deleteCandidate.key); + } + }} + > + Delete voice + + + + ); } diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 97051b68ec..818144415a 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -166,6 +166,8 @@ type E2eConfig = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */ @@ -9911,7 +9913,25 @@ export function maybeInstallE2eTauriMocks() { deviceId: state === "running" ? "mock-endpoint-id" : null, deviceName: state === "running" ? "Mock desktop" : null, }); - const handleMockCommand = async (command: string, payload: unknown) => { + let mockImportedVoices: Array<{ + key: string; + displayName: string; + backend: string; + backendName: string; + availability: "installed"; + fallbackKey: string; + referenceFile: string; + provenance: { + source: string; + contentHash: string; + license: null; + sourceUrl: null; + }; + }> = []; + const handleMockCommand = async ( + command: string, + payload: unknown, + ): Promise => { const activeConfig = getConfig(); const identity = getActiveIdentity(activeConfig); window.__BUZZ_E2E_COMMANDS__?.push(command); @@ -9969,107 +9989,110 @@ export function maybeInstallE2eTauriMocks() { ); case "list_voice_registry": return [ - [ - "anna", - "Anna", - "anna.wav", - "p228_023_enhanced.wav", - "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", - ], - [ - "vera", - "Vera", - "vera.wav", - "p229_023_enhanced.wav", - "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", - ], - [ - "fantine", - "Fantine", - "fantine.wav", - "p244_023_enhanced.wav", - "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", - ], - [ - "charles", - "Charles", - "charles.wav", - "p254_023_enhanced.wav", - "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", - ], - [ - "paul", - "Paul", - "paul.wav", - "p259_023_enhanced.wav", - "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", - ], - [ - "eponine", - "Eponine", - "eponine.wav", - "p262_023_enhanced.wav", - "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", - ], - [ - "azelma", - "Azelma", - "azelma.wav", - "p303_023_enhanced.wav", - "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", - ], - [ - "george", - "George", - "george.wav", - "p315_023_enhanced.wav", - "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", - ], - [ - "mary", - "Mary", - "reference_sample.wav", - "p333_023_enhanced.wav", - "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", - ], - [ - "jane", - "Jane", - "jane.wav", - "p339_023_enhanced.wav", - "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", - ], - [ - "michael", - "Michael", - "michael.wav", - "p360_023_enhanced.wav", - "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", - ], - [ - "eve", - "Eve", - "eve.wav", - "p361_023_enhanced.wav", - "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", - ], - ].map( - ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ - key: `pocket:${id}`, - displayName, - backend: "pocket", - backendName: "Pocket TTS", - availability: "bundled", - fallbackKey: id === "mary" ? null : "pocket:mary", - referenceFile, - provenance: { - source: "bundled", - contentHash, - license: "CC-BY-4.0", - sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, - }, - }), - ); + ...[ + [ + "anna", + "Anna", + "anna.wav", + "p228_023_enhanced.wav", + "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856", + ], + [ + "vera", + "Vera", + "vera.wav", + "p229_023_enhanced.wav", + "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b", + ], + [ + "fantine", + "Fantine", + "fantine.wav", + "p244_023_enhanced.wav", + "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b", + ], + [ + "charles", + "Charles", + "charles.wav", + "p254_023_enhanced.wav", + "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756", + ], + [ + "paul", + "Paul", + "paul.wav", + "p259_023_enhanced.wav", + "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b", + ], + [ + "eponine", + "Eponine", + "eponine.wav", + "p262_023_enhanced.wav", + "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b", + ], + [ + "azelma", + "Azelma", + "azelma.wav", + "p303_023_enhanced.wav", + "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026", + ], + [ + "george", + "George", + "george.wav", + "p315_023_enhanced.wav", + "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae", + ], + [ + "mary", + "Mary", + "reference_sample.wav", + "p333_023_enhanced.wav", + "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f", + ], + [ + "jane", + "Jane", + "jane.wav", + "p339_023_enhanced.wav", + "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a", + ], + [ + "michael", + "Michael", + "michael.wav", + "p360_023_enhanced.wav", + "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad", + ], + [ + "eve", + "Eve", + "eve.wav", + "p361_023_enhanced.wav", + "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd", + ], + ].map( + ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({ + key: `pocket:${id}`, + displayName, + backend: "pocket", + backendName: "Pocket TTS", + availability: "bundled", + fallbackKey: id === "mary" ? null : "pocket:mary", + referenceFile, + provenance: { + source: "bundled", + contentHash, + license: "CC-BY-4.0", + sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`, + }, + }), + ), + ...mockImportedVoices, + ]; case "set_tts_enabled": { const enabled = (payload as { enabled?: boolean })?.enabled; if (typeof enabled !== "boolean") @@ -10114,6 +10137,75 @@ export function maybeInstallE2eTauriMocks() { } case "preview_pocket_voice": return null; + case "import_pocket_voice": { + const importResult = + activeConfig?.mock?.pocketVoiceImportResult ?? "success"; + if (importResult === "cancel") return null; + if (importResult === "invalid") { + throw new Error("Voice WAV must contain PCM or 32-bit float audio"); + } + const contentHash = "1".repeat(64); + const imported = { + key: `pocket:imported:${contentHash}`, + displayName: "My voice", + backend: "pocket", + backendName: "Pocket TTS", + availability: "installed" as const, + fallbackKey: "pocket:mary", + referenceFile: `${contentHash}.wav`, + provenance: { + source: "local import", + contentHash, + license: null, + sourceUrl: null, + }, + }; + mockImportedVoices = [imported]; + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const settings = { + ...current, + voicePreferences: [imported.key], + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return { + settings, + registry: await handleMockCommand("list_voice_registry", null), + }; + } + case "delete_pocket_voice": { + const voiceKey = (payload as { voiceKey?: string })?.voiceKey; + if (!voiceKey?.startsWith("pocket:imported:")) + throw new Error("Missing imported Pocket voice key"); + mockImportedVoices = mockImportedVoices.filter( + (voice) => voice.key !== voiceKey, + ); + const current = activeConfig?.mock?.ttsSettings ?? { + version: 1, + agentTextToSpeech: true, + voicePreferences: ["pocket:mary"], + }; + const settings = { + ...current, + voicePreferences: current.voicePreferences.includes(voiceKey) + ? ["pocket:mary"] + : current.voicePreferences, + }; + if (activeConfig) { + activeConfig.mock ??= {}; + activeConfig.mock.ttsSettings = settings; + } + return { + settings, + registry: await handleMockCommand("list_voice_registry", null), + }; + } case "get_builderlab_auth": return activeConfig?.mock?.builderlabAuth ?? null; case "start_builderlab_login": { diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts index 490bee3424..9bd8742ed7 100644 --- a/desktop/tests/e2e/voice-settings.spec.ts +++ b/desktop/tests/e2e/voice-settings.spec.ts @@ -116,4 +116,97 @@ test.describe("Pocket voice settings", () => { }, }); }); + + test("imports, selects, and safely deletes a local voice", async ({ + page, + }) => { + await installMockBridge(page); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "My voice", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeVisible(); + await page.getByRole("button", { name: "Preview" }).click(); + + await page.getByTestId("pocket-voice-delete").click(); + await expect(page.getByText("Delete imported voice?")).toBeVisible(); + await page.getByTestId("confirm-pocket-voice-delete").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await page.getByRole("button", { name: "Preview" }).click(); + + const mutations = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []) + .filter((entry) => + [ + "import_pocket_voice", + "preview_pocket_voice", + "delete_pocket_voice", + ].includes(entry.command), + ) + .map((entry) => ({ command: entry.command, payload: entry.payload })), + ); + expect(mutations).toEqual([ + { command: "import_pocket_voice", payload: {} }, + { + command: "preview_pocket_voice", + payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, + }, + { + command: "delete_pocket_voice", + payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` }, + }, + { + command: "preview_pocket_voice", + payload: { voiceKey: "pocket:mary" }, + }, + ]); + }); + + test("keeps the selected voice unchanged when the native picker is cancelled", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "cancel" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + await expect(page.getByTestId("voice-settings-error")).toBeHidden(); + + const audioCommands = await page.evaluate(() => + (window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter((entry) => + ["preview_pocket_voice", "delete_pocket_voice"].includes(entry.command), + ), + ); + expect(audioCommands).toEqual([]); + }); + + test("surfaces invalid or unsupported WAV errors without changing selection", async ({ + page, + }) => { + await installMockBridge(page, { pocketVoiceImportResult: "invalid" }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await openSettings(page, "voice"); + + await page.getByTestId("pocket-voice-import").click(); + await expect(page.getByTestId("voice-settings-error")).toContainText( + "Voice WAV must contain PCM or 32-bit float audio", + ); + await expect(page.getByTestId("pocket-voice-selector")).toContainText( + "Mary", + ); + await expect(page.getByTestId("pocket-voice-delete")).toBeHidden(); + }); }); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 2d41d27a5e..8a9ab2be11 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -164,6 +164,8 @@ type MockBridgeOptions = { agentTextToSpeech: boolean; voicePreferences: string[]; }; + /** Native picker boundary result for Pocket voice import tests. */ + pocketVoiceImportResult?: "success" | "cancel" | "invalid"; /** Advertised HEAD for the first mock project without adding that branch. */ projectHeadBranch?: string; /** Relay NIP-11 identity used to sign authoritative repository state. */ From 052174a148f9f6bcbb2b5a1d20ce0317645e49f8 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 10:39:14 -0600 Subject: [PATCH 40/87] fix(release): make immutable desktop release operable (#3943) ## Summary - document `Prepare Desktop Release` as the canonical desktop release entry point - describe the frozen candidate, exact-head approval, and true merge-commit contract - document all platform outputs and complete release App/signing configuration - link the release runbook from the README - allow stable reruns to repair the rolling updater manifest after the versioned release has already published ## Release blocker The live repository cannot currently complete this flow: repository settings disable merge commits and the `main` ruleset allows only squash, while `scripts/verify-desktop-release-merge.sh` requires a two-parent merge whose second parent is the approved candidate. Those settings must allow merge commits before a desktop release PR is merged. ## Validation - `bash scripts/test-desktop-release-candidate.sh` - `bash scripts/test-release-ref-contract.sh` - `git diff --check` - verified live repository merge settings, `main` ruleset, release tag ruleset, Actions variable names, and secret names with GitHub API - independent review by Princess Donut; incorporated all findings, including the rolling-manifest retry gap and unsigned Windows labeling Signed-off-by: Wes Co-authored-by: Carl --- .github/workflows/release.yml | 2 +- README.md | 1 + RELEASING.md | 113 ++++++++++++++++++++------- scripts/test-release-ref-contract.sh | 5 +- 4 files changed, 89 insertions(+), 32 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 07951ef81d..7d5f3fbf40 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -948,5 +948,5 @@ jobs: run: gh release edit "desktop-v${VERSION}" --draft=false - name: Upload latest.json to rolling release last - if: ${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }} + if: ${{ !contains(needs.setup.outputs.version, '-') }} run: gh release upload buzz-desktop-latest latest.json --clobber diff --git a/README.md b/README.md index 72af92ce13..2c58ceecad 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Forge · Agents · Architecture · + Releasing · Apache 2.0

diff --git a/RELEASING.md b/RELEASING.md index 45f0f8638f..11f669fc9b 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `Prepare Desktop Release` / `just release-desktop` | Signed desktop app (macOS/Linux) | +| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,13 +16,22 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start +Desktop releases are prepared from the current remote `main` by GitHub Actions: + ```sh -# Desktop release (next patch version) -just release-desktop +gh workflow run prepare-desktop-release.yml \ + --repo block/buzz \ + --ref main \ + -f version=0.5.3 +``` -# Desktop explicit version -just release-desktop 0.4.0 +The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run +workflow**, select `main`, enter the version without a `v` prefix, and run it. +The local `just release-desktop ` recipe uses the same candidate script, +but the Actions workflow is the canonical operator path because it runs with the +release App identity and does not depend on an operator checkout. +```sh # Relay release just release-relay just release-relay 0.4.0 @@ -31,8 +40,9 @@ just release-relay 0.4.0 scripts/mobile-release.sh candidate 0.5.0 ``` -Desktop uses an immutable generated candidate PR; relay continues using its metadata PR. Mobile does not. Each -`mobile-vX.Y.Z-rc.N` tag is an immutable candidate and the artifact of record. +Desktop uses an immutable generated candidate PR; relay continues using its +metadata PR. Mobile does not. Each `mobile-vX.Y.Z-rc.N` tag is an immutable +candidate and the artifact of record. There is no mobile release branch, stable mobile tag alias, finalization step, or mobile GitHub Release. @@ -42,11 +52,26 @@ or mobile GitHub Release. ### Desktop -1. Run **Prepare Desktop Release** with a version (or `just release-desktop `). Automation records current `origin/main`, regenerates `version-bump/` as one deterministic candidate commit, and opens or updates the PR. -2. Review the full-SHA changelog, CI, recorded base, and candidate SHA. Any regeneration creates a new head and requires fresh approval. -3. Merge with **Create a merge commit**. Squash and rebase are invalid for desktop release PRs. -4. `auto-tag-on-release-pr-merge` proves that merge parent 2 is the exact approved candidate, then tags that candidate `desktop-v`. -5. The tag triggers `release.yml`. It creates a draft, builds and stages every platform, publishes the complete versioned release, and updates the rolling updater manifest last for stable versions. +1. Run **Prepare Desktop Release** with an explicit version. Automation 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 candidate must receive an approval on its exact current head. Any + regeneration changes that head and therefore requires a fresh approval. +3. Merge with **Create a merge commit**. Squash and rebase are invalid for + desktop release PRs. Repository settings and the `main` ruleset must allow + merge commits for this option to exist. +4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate + approval, and every required check, then tags the reviewed candidate—not the + merge commit—as `desktop-v`. +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. ### Relay @@ -143,12 +168,15 @@ for distributable builds or builds from an immutable release tag. --- -## Manual Release Retry +## Release Retry -The **Release** workflow's manual dispatch is only a retry mechanism for an -existing immutable `desktop-v` tag. Select that tag in the ref picker and -provide the matching semver version without the `desktop-v` prefix. It cannot build -from `main` or another caller-selected source ref. +`release.yml` has no manual dispatch and cannot build from `main` or another +caller-selected ref. If a run for an existing immutable +`desktop-v` tag fails, rerun that failed workflow from GitHub Actions +(or use `gh run rerun --failed --repo block/buzz`). A stable rerun also +repairs `buzz-desktop-latest/latest.json` if the original run published the +versioned release but failed during that final rolling-manifest upload. Do not +recreate, move, or push the immutable tag again. Mobile intentionally has no branch or arbitrary-ref fallback. The private Buildkite pipeline accepts only an exact candidate tag. @@ -183,9 +211,11 @@ GitHub Release or a stable `mobile-vX.Y.Z` alias. The release workflow builds **two separate macOS DMGs**: Apple Silicon (`darwin-aarch64`, the `release` job) and Intel -(`darwin-x86_64`, the `release-macos-x64` job), plus Linux `.deb` and -`.AppImage`. Both macOS DMGs are codesigned, notarized, and attached to -the same `desktop-v` release. Intel users download the `_x64.dmg`. +(`darwin-x86_64`, the `release-macos-x64` job), an unsigned Windows x64 +NSIS installer (its filename includes `_alpha-unsigned`), and Linux `.deb` and +`.AppImage` packages. Both macOS DMGs are codesigned, notarized, and attached +to the same `desktop-v` release. Intel users +download the `_x64.dmg`. The Linux AppImage is post-processed by `desktop/scripts/fix-appimage.sh`, which strips infra libraries over-bundled by linuxdeploy (they crash on @@ -205,18 +235,25 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 repository - `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch the candidate workflow +- Repository settings and the `main` ruleset configured to allow **merge + commits**; desktop release PRs cannot be squash- or rebase-merged - Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) - active for `mobile-v*`, with creation, update, deletion, and non-fast-forward - protections and `buzz-release-bot` as its sole always-bypass actor + active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and + non-fast-forward protections and `buzz-release-bot` as its sole always-bypass + actor - The `buzz-release-bot` App credentials configured for GitHub Actions -- The following **GitHub Actions secrets** must also be configured for the +- The following **GitHub Actions variables and secrets** configured for the desktop release lane: - | Secret | Purpose | - |--------|---------| - | `BUZZ_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) | - | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key | - | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | + | Name | Kind | Purpose | + |------|------|---------| + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags | + | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | + | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | + | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | + | `BUZZ_UPDATER_PUBLIC_KEY` or `SPROUT_UPDATER_PUBLIC_KEY` | Secret | Tauri updater public key | + | `TAURI_SIGNING_PRIVATE_KEY` | Secret | Tauri updater private key | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Secret | Password for the private key | Mobile candidate publication requires workflow-dispatch access and the existing release App because strict tag protection denies direct human creation. The App @@ -231,10 +268,26 @@ actor list. ## Troubleshooting -### `just release-desktop` fails with "must be on main branch" +### The release PR does not offer **Create a merge commit** + +The immutable desktop flow cannot release until both the repository merge +settings and the `main` ruleset allow merge commits. Do not squash the PR: the +auto-tagger deliberately rejects a one-parent squash commit. Enable merge +commits, then merge the already-approved exact candidate head with **Create a +merge commit**. + +### `Prepare Desktop Release` fails before opening a PR + +Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and +`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can +write contents and pull requests. Rerunning the preparer regenerates the +candidate from the then-current `origin/main`; if its head changes, obtain a new +approval before merging. + +### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. -### `just release-desktop` fails with "working tree is dirty" +### Local `just release-desktop` fails with "working tree is dirty" Commit or stash your changes before running the release recipe. ### New commits land after publishing a mobile candidate diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index bd4eb75275..25ef5ca230 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -120,7 +120,10 @@ grep -Fq "needs.release-macos-x64.result == 'success'" "$release_workflow" grep -Fq "needs.release-linux.result == 'success'" "$release_workflow" grep -Fq "needs.release-windows.result == 'success'" "$release_workflow" grep -Fq "refs/tags/desktop-v{0}" "$release_workflow" -grep -Fq "if: \${{ env.already_published != 'true' && !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +grep -Fq "if: \${{ !contains(needs.setup.outputs.version, '-') }}" "$release_workflow" +if grep -Fq "env.already_published != 'true' && !contains(needs.setup.outputs.version, '-')" "$release_workflow"; then + echo "rolling updater retry is incorrectly gated by versioned publication state" >&2; exit 1 +fi grep -Fq 'group: desktop-release-${{ github.ref }}' "$release_workflow" grep -Fq 'cancel-in-progress: false' "$release_workflow" grep -Fq 'release artifact basename collision' "$release_workflow" From d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 10:43:30 -0600 Subject: [PATCH 41/87] chore(release): release Buzz Desktop version 0.5.3 Co-authored-by: Release Automation Signed-off-by: Wes --- .release/desktop-candidate.json | 8 ++++ CHANGELOG.md | 63 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 .release/desktop-candidate.json diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..150f8023e7 --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.3", + "base_sha": "052174a148f9f6bcbb2b5a1d20ce0317645e49f8", + "previous_tag": "v0.5.2", + "tag": "desktop-v0.5.3", + "commit_count": 53 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d83087fc26..974f68c683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,68 @@ # Changelog +## v0.5.3 + +### Desktop and shared changes + +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..e8145f5468 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 254b7070ac..00d3fba3b5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 39aaf0dead..b80684f955 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2eba7815b2..1ff8bd20ef 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.2", + "version": "0.5.3", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From 209536ade6c5ebf7fa82671d7ca0b74f599a40cc Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 31 Jul 2026 13:06:21 -0400 Subject: [PATCH 42/87] docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS (#2864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Amends `docs/nips/NIP-RS.md` with the manual mark-as-unread override layer and includes `docs/formal/nip-rs-unread/`, the bounded exhaustive verification model that preceded and informed the spec. All `ov_*` override state lives in exactly one coordinate per installation. That single constraint is what makes the rest of the amendment small: override state never moves between coordinates, so there is no slot lifecycle to make crash-safe, and the only durability obligation is carry-forward on `client_id` rotation. ## Spec changes (`docs/nips/NIP-RS.md`) - **Non-Goals:** drop the stale line stating mark-as-unread is out of scope; state the `ov_*` durability exception to the best-effort/time-horizon model. - **Reserved Namespace:** `ov_` stem and `esc:` escape marker reserved. Escape on publish (prepend `esc:` to raw IDs beginning with `ov_` or `esc:`), unescape on receive (strip exactly one `esc:`). Bijection, with the pre-amendment backward-compat residual documented as a stated limitation. - **Content Validation:** override entries are collected and validated as a complete logical group *before* any decoding, zero-filling, merging, or canonicalizing. Only two wire shapes are accepted — a complete live three-key group, or an `ov_c:`-only tombstone floor. Any other shape rejects the whole group while retaining the frontier entry; applying the generic per-entry discard rule first is prohibited. - **`d` Tag:** `` is exactly 32 lowercase hexadecimal characters, replacing "a random opaque string" of 1–64 ASCII characters. The fixed shape lets a relay recognize a read-state coordinate structurally from the `d` tag alone, without decrypting anything, and apply per-coordinate protections to it — under the old wording a conforming client could pick a shape that silently forfeits them. Recognizable coordinates are also what let a relay replace superseded versions outright rather than accumulating one retained row per publish, which keeps the coordinate count a full-state load must enumerate near one per installation. Every client designates one **primary** coordinate with a stable `` for the installation's lifetime. All `ov_*` entries, and the frontier entries of the contexts they belong to, MUST live in the primary. Additional coordinates remain legal for frontier volume but MUST NOT carry `ov_*`, which keeps them freely rewritable and freely deletable. - **`t` Tag:** described as a discoverability marker rather than a guarantee of relay-side selectivity. A relay MAY apply tag constraints after its result cap, and `kind:30078` is shared with unrelated application data, so clients MUST apply the tag as a correctness filter locally, MUST NOT infer completeness from a short result, and MUST omit the tag entirely when performing a full-state load. - **Fetching / Full-State Load:** clients implementing the override layer MUST NOT apply a finite `since` filter — an encrypted payload means a relay filter cannot select for override-bearing events, so any event-level window can exclude the only coordinate holding a tombstone floor. Removing `since` is not sufficient: relays MAY cap historical results, MAY cap below the requested `limit`, and emit end-of-stored-events after the capped query, so neither EOSE nor a short page proves completeness. No test against the client's requested `limit` can detect truncation either: the effective cap belongs to the relay, a relay MAY cap below what was requested, and an advertised maximum limit is not necessarily the limit enforced. A full-state load is therefore enumerated on `{"kinds": [30078], "authors": [], "limit": }` with **no tag constraint**. A relay MAY apply tag constraints only after its result cap and withhold the events that fail them, so under a tag-constrained filter the delivered count is not the count the cap selected — a delivered page can be empty while older coordinates still exist below it, and `kind:30078` is arbitrary application data whose `d` tag namespace is open to every application that has written under the user's key. Omitting the tag makes delivery observable; read-state selection moves client-side, where the validation rules already place it. Completeness is then established by enumeration on a strictly decreasing cursor: collect a page, descend on the lowest `created_at` across all delivered events, exhaust that second with a window pinned to it, continue below it, and treat only an empty delivery as complete. Every query carries the same explicit `limit` `n` with `n >= L`. Per-second exhaustion is discharged by comparing the pinned window's delivery against the largest delivery the relay has already demonstrated in the same load, floored at `L = 2` so that the ordinary single-coordinate installation can reach *complete* at all. The comparison fails safe: an inconclusive window reports *cannot prove complete* rather than *complete*, and that verdict is terminal for the load. Because these are addressable events, a coordinate republished mid-load moves *above* the descending cursor while its previous version stops existing, so neither is reachable by any later query. A full-state load is therefore fenced by a live subscription on the same tag-free filter, established — defined as receipt of end-of-stored-events — before the first enumeration query and held unbroken on the same connection for the load's duration. Fence deliveries are collected like enumerated events but do not contribute to the cursor or to the demonstrated-delivery bound. Collection deduplicates coordinates on the full NIP-01 addressable ordering — greatest `created_at`, lowest event id on ties — because an equal-timestamp replacement is legal and is the version the relay retains. A lapsed or reconnected fence makes the load potentially incomplete, and a client MUST NOT publish to its own coordinates during its own load. Five relay behaviours the *complete* verdict rests on are stated as normative conformance preconditions rather than assumptions, because none is verifiable from the responses a client receives: newest-first prefix delivery with lowest-id tie-breaking (what NIP-01 already specifies for `limit`), a non-decreasing effective cap within a load, the floor `L`, push delivery on an open subscription, and a delivery barrier ordering accepted matching events ahead of a query's end-of-stored-events on the same connection. Conditioning *complete* on positive proof of these instead would withdraw the override layer from every client rather than from the non-conforming relays. A client MUST NOT load against a relay it has evidence violates them, and MUST treat any such load as potentially incomplete. A load that is potentially incomplete, or that failed on any relay the client publishes to, MUST NOT authorize canonical compaction, publishing a canonicalized override blob, deleting or abandoning a coordinate, or reporting a mark-read as successful; the client falls back to local state. - **Client-ID Rotation / Orphaned Blob Deletion:** rotation is the only event that changes an override-bearing coordinate. Before deleting or abandoning its previous primary, a client MUST republish the componentwise `max()` of every register that primary holds — every tombstone ceiling included — under its new primary, and MUST confirm acceptance on **every relay** from which the old primary will be deleted or allowed to lapse. Acceptance on one relay does not authorize deletion on another. Frontier-only orphans are deletable unconditionally; an unknown same-`client_id` coordinate is treated as a live carrier until merged. - **Live Subscription and Convergence:** the re-publish trigger and its suppression are evaluated on canonicalized state, so a retained live peer blob the client has already tombstoned cannot trigger an identical write on every replay. - **Manual-Unread Override Layer** (new section): - **Wire encoding:** `ov_s:`, `ov_c:`, `ov_b:` as uint32 siblings in the existing `contexts` map. - **Merge rule:** componentwise `max()` per counter — no new wire merge logic. - **Liveness predicate:** `S > 0 AND F <= B AND S > C`, transcribed from `model.py::override_set_b`. - **Actions:** mark-unread bumps S and captures the effective frontier as B; mark-read bumps C; a natural frontier advance past B deactivates a stale set with no counter update. Every action requires a complete full-state load. At the uint32 ceiling, wrapping and resetting are prohibited: mark-unread is refused, and mark-read completes only if the resulting state has `override_active == false` — otherwise it fails visibly rather than reporting success over a still-live override. - **Tombstone floor:** a dead ever-active register compacts to `RegB(0, max(S,C), 0)` — a single `ov_c:` key. A virgin register is omitted entirely. This blocks counter reuse and the resulting resurrection. - **Mandatory canonical publication:** a protocol requirement, not an optimization. Publishing raw dead registers lets two independently-dead registers from different devices produce a live join. - **Override group co-location rule:** a context's frontier entry and all its `ov_*` siblings MUST travel in the same event, and that event MUST be the primary coordinate. An override-bearing context therefore has exactly one legal destination for its whole group; only frontier-only groups may be distributed across additional coordinates. Grouping is per logical context, never per key. - **Unescape-before-group rule:** the frontier wire key MUST be unescaped to its raw logical context ID before use as group identity. Equal normative weight to atomic grouping. - **Tie policy:** clear-wins is MUST. The tie verdict is not encoded on the wire, so a selectable policy makes two conforming clients diverge permanently on both the unread verdict and the canonical wire form. - **Override State Durability:** `ov_*` entries are exempt from age pruning and budget eviction permanently, and durability is defined over retrievable logical state — the containing event must stay reachable and the load must establish completeness, not merely retain keys. There is no safe finite GC horizon. - **Bounds and budget:** byte/key analysis at both small-counter and uint32-maximum values. Confining `ov_*` to one blob makes its plaintext budget a hard lifetime ceiling on ever-overridden contexts — roughly 600 tombstones at the worst-case ~54 bytes against 32 KiB, ~730 at the common ~45 bytes, ~199 simultaneously live overrides at ~164 bytes. At the ceiling a client MUST refuse mark-unread and MUST NOT split override state, drop floors, or publish a truncated override set. Same policy shape as counter exhaustion: visible failure, never silent degradation. - **Verification artifact:** `docs/formal/nip-rs-unread/`. The model is a broader predecessor of this NIP: its `split_blob_into_slots` permits override groups in any slot, so verified atomicity covers every arrangement this NIP allows, but the converse does not follow. The model does not verify the single-primary rule, the completeness procedure, the relay conformance requirements or the mutation fence, or carry-forward; malformed-group wire validation is likewise normative but outside verified scope. - **Abstract / Non-Goals / Backwards Compatibility:** the absolute "no relay-side logic" and "no relay behavior changes" claims are narrowed to what remains true — no new event kind, no new wire message, no relay-stored read-state logic — with the override layer's relay conformance contract named as the exception. Frontier sync and clients that skip the override layer are unaffected on any relay. ## Verification model (`docs/formal/nip-rs-unread/`) Four Python files constituting a bounded exhaustive verification model for the override layer's register algebra. **What it does:** constructs a toy universe — 2–3 devices, 2 channels, every action that can happen (mark-unread, mark-read, late/duplicate syncs, app reinstall, storage compaction) — and brute-forces every reachable ordering (14,258 BFS states; 672-point deep-history parameter cube; 9-mutant harness over ~45,000 merge pairs). After each world-state it asks: did all devices converge? Did any unread flag get resurrected after being cleared, or vanish while live? **What it found and fixed:** 1. **Killed candidate A.** The model produced a concrete kill sequence: an old client that doesn't know about the new field rewrites its read-state blob and silently erases unread flags. That witness is why the spec uses candidate B (two counters that only count up, plus a snapshot) instead. 2. **Candidate B passes everything.** All delivery orders converge; the frontier high-water mark never regresses; duplicated/replayed syncs are harmless; old clients can't destroy it; compaction never resurrects a dead unread or drops a live one, including cleanup-followed-by-weeks-late-stale-sync and tombstone-landing-on-unrelated-live-state corner cases. 3. **Caught a second real bug late.** Two devices each publishing "this unread is cleared" could, on merge, reactivate it. The fix (canonicalize before publishing) is a mandatory rule in the spec; the model re-checks it across ~45,000 merge pairs. **Scope and caveats:** bounded to 2–3 devices and 2 channels. Can't prove the infinite case. `NOTE.md` documents the exact verification scope and the gap between the model's `split_blob_into_slots` generality and the single-primary rule the spec adds on top. **Why it's in the repo:** the spec asserts "verified by bounded exhaustive model checking." Keeping the artifact in-repo means anyone who later amends the merge/compaction rules can `python3 exhaustive.py && python3 mutation.py` (deterministic, exit 0) and confirm the guarantees hold. Without it the spec claims a proof nobody can check. ## Diff scope `docs/nips/NIP-RS.md` — spec amendment, zero product code. `docs/formal/nip-rs-unread/{NOTE.md,model.py,exhaustive.py,mutation.py}` — bounded exhaustive verification model, zero product code. `.gitignore` — `__pycache__/` and `*.pyc` entries for the model directory. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .gitignore | 4 + docs/formal/nip-rs-unread/NOTE.md | 698 +++++++++++ docs/formal/nip-rs-unread/exhaustive.py | 1486 +++++++++++++++++++++++ docs/formal/nip-rs-unread/model.py | 492 ++++++++ docs/formal/nip-rs-unread/mutation.py | 519 ++++++++ docs/nips/NIP-RS.md | 289 ++++- 6 files changed, 3458 insertions(+), 30 deletions(-) create mode 100644 docs/formal/nip-rs-unread/NOTE.md create mode 100644 docs/formal/nip-rs-unread/exhaustive.py create mode 100644 docs/formal/nip-rs-unread/model.py create mode 100644 docs/formal/nip-rs-unread/mutation.py diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..f26e74136c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ /dist/ /admin-web/dist/ +# Python cache +__pycache__/ +*.pyc + # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/docs/formal/nip-rs-unread/NOTE.md b/docs/formal/nip-rs-unread/NOTE.md new file mode 100644 index 0000000000..7472c3dca3 --- /dev/null +++ b/docs/formal/nip-rs-unread/NOTE.md @@ -0,0 +1,698 @@ +--- +title: "NIP-RS manual-unread: bounded exhaustive model — candidates A vs B" +tags: [nostr, nip-rs, read-state, formal-model, buzz] +status: active +created: 2026-07-16 +--- + +# NIP-RS manual-unread encoding model + +Bounded exhaustive model comparing two candidate CRDT encodings for a +manual mark-as-unread override layer within NIP-RS read state. + +## Run + +```bash +python3 exhaustive.py +python3 mutation.py +``` + +Both scripts are deterministic and exit 0 on success. + +## Context + +NIP-RS v1 encodes read state as grow-only `max(timestamp)` frontiers per +context. Manual mark-as-unread requires a second source of truth (an +override layer) because the frontier cannot be lowered — a lower value is +indistinguishable from a stale replica under `max()` merge. + +The override layer must converge across devices, survive legacy client +rewrite cycles, and remain bounded within the existing 32 KiB plaintext +budget. Two candidate encodings are modeled: + +- **A — lexicographic operation register:** per context, one register + `{counter, client_tiebreak, op, baseline}` in a NEW top-level field. +- **B — two grow-only counters + baseline:** per context, `S` (set + counter), `C` (clear counter), `B` (frontier-at-set-time) encoded as + sibling keys under `contexts`. + +## Model universe + +- 2 upgraded devices + 1 legacy device +- 2 contexts (`c0`, `c1`) +- Actions: mark-unread, mark-read (with frontier advance), + advance-frontier, compact, reinstall (client_id loss), + deliver (including duplicate/replay) +- BFS over canonical global states with interleaved actions and deliveries + (not phased), depth-bounded +- All delivery permutations of published blobs at terminal states +- Multi-slot union (split blob across 2 slots, deliver separately) +- Directed deep-history check: compact → new local actions (counter + reuse) → delayed stale delivery, over a 672-point parameter cube + (stale `(S,C,B)` × post-compaction frontier × 7 action sequences × + 2 tie policies × 1 delivery shape). The prior 2,016-point count + included two duplicate split-delivery shapes (`split_fwd`/`split_rev`) + that became semantically identical to `single` once the atomic-grouping + rule made a single-context compliant split always whole-register+empty; + collapsed to one meaningful shape without loss of register-level + coverage. +- Cross-device compaction transparency check: same tombstone, delivered + to an unrelated device with its own live concurrent state, over a + 312-point parameter cube (stale `(S,C,B)` × post-compaction frontier × + 4 fresh-frontier values × 2 tie policies), plus a monotonicity lemma + over 1,728 points (2 tie policies × 4×4×3×3 receiving-register/frontier + combinations × 6 ceiling values) proving the ceiling can never + *strengthen* a receiving register's set-counter standing +- States explored: 7,129 per tie policy (14,258 total) +- Published-state merge closure: every override is canonicalized against + the device's own effective frontier at serialization time before + hitting the wire (mandatory, not optional) — live unchanged, dead + folded to the tombstone floor, virgin omitted. Checked over a directed + witness (Thufir's exact dead+dead pair) plus a general search: every + pairwise join of a bounded cube of 300 independently-dead published + states (156 clear-wins + 144 set-wins = 300 total across both tie + policies), including a one-hop relay republication to cover + delayed/multi-hop delivery — 45,074 pairs checked total (156² + 144² + + 2 directed witnesses) + +## Invariants checked + +| # | Invariant | A | B (clear-wins) | B (set-wins) | +|---|-----------|---|-----------------|--------------| +| I1 | Join associative/commutative/idempotent | PASS | PASS | PASS | +| I2 | Convergence (all delivery orders) | not exercised | PASS | PASS | +| I3 | No frontier regression | not exercised | PASS | PASS | +| I4 | Concurrent set/clear winner stable | not exercised | PASS | PASS | +| I5 | Compaction: no loss, no resurrection (immediate merge-back) | n/a | PASS | PASS | +| I5c | Deep-history: compact → reuse → delayed stale delivery (same-device replay) | n/a | PASS | PASS | +| I5d | Cross-device compaction transparency (suppress-only, not zero-divergence) | n/a | PASS | PASS | +| I5e | Published-state merge closure: dead+dead join stays inactive | n/a | PASS | PASS | +| I6 | Replay harmless | not exercised | PASS | PASS | +| I7 | Legacy rewrite safety | **FAIL** (witness) | PASS | PASS | +| I8 | Bounded key growth (3 keys/ctx live, 1 key/ctx tombstone) | n/a | PASS | PASS | +| I9 | DeviceA counter absorption | PASS | n/a | n/a | + +Note: Candidate A is exercised only for I1, I7, and I9. BFS/convergence, +frontier-regression, concurrent-winner, and replay tests (I2–I4, I6) are +Candidate B-only; adding A variants would fail minimalism since A is already +dead on I7 (legacy-rewrite erasure). + +I5 covers the immediate compacted-vs-pre-compaction merge shape (both +merge orders). I5c is the same-device deep-history property this round +was originally opened to close: it directly targets the ~9-transition +history a depth-4 BFS cannot structurally reach (compact → new local +set/clear → delayed stale delivery, including from a second slot), +asserting that compaction never resurrects a dead override or drops a +live one **when the delayed delivery is the compacting device's own +pre-compaction ancestor** (or an exact copy of it, e.g. a peer that +never advanced past the original snapshot). + +**I5c does not cover, and NOTE.md previously overstated, the +cross-device case.** Compaction is a storage optimization from the +compacting device's own point of view — its dead register's baseline +`B` was frontier-relative to *that device's* history, and dropping `S` +in favor of the `C` ceiling is safe against replays of *its own* past. +But once published, the tombstone's `C` ceiling is globally comparable +via componentwise `max()`, while the baseline-relative death that +produced it is not. I5d proves the resulting property precisely: +merging in a tombstone can **suppress** — never resurrect, per the +`test_tombstone_merge_monotonic` structural lemma — a different +device's concurrent fresh set whose own counters happen to be at or +below the tombstone's ceiling, and the suppression always recovers with +one more local mark-unread (verified replay-stable against the same +tombstone). This is a one-shot false-negative risk, not a correctness +violation of the CRDT join (idempotent/commutative/associative still +hold per I1) and not new: an *uncompacted* stale explicit clear already +suppresses a fresh concurrent set under clear-wins with no compaction +anywhere (verified directly — see "Tie policy evidence" below); the +tombstone extends the same false-negative-preferring shape to +baseline-dominated dead sets that were never explicitly cleared. + +**I5e — published-state merge closure — is a protocol requirement, not +an optimization.** I5d's suppress-only guarantee assumes the tombstone +was actually on the wire before the merge. Nothing forces that: +`compact_b()`/`do_compact` are a local storage-GC transition a device +may or may not have called before it serializes. Without a mandatory +canonicalization step, `publish_blob()` can emit a register's *raw* +`(S, C, B)` — dead by construction (baseline-dominated, clear-dominated, +or a clear-wins tie) but not yet folded into the tombstone's +globally-comparable `C` ceiling. Two such raw-dead registers, published +by two different devices for unrelated reasons, can componentwise-max +into a **live** join: each register's `S` and `B` came from a different +device history, and the merge recombines them independent of either +history's own death cause. This is a distinct hazard from I5d's +suppression (I5d is a live register losing to a stale dead one; I5e's +witness is two dead registers producing a live one) but the same root +cause — components taken from independent histories can be +recombined in ways neither history's own frontier ever permitted. + +**Fix: canonical publication is mandatory, not advisory.** +`DeviceB.publish_blob()` now canonicalizes every override against the +device's own effective frontier at serialization time, unconditionally +— live unchanged (3 keys), dead folded to the tombstone floor `RegB(0, +max(S,C), 0)` (1 key), virgin omitted (0 keys) — regardless of whether +`do_compact` was ever called locally first. This is a **spec-amendment +requirement for any production client implementing this override +layer**: publication MUST canonicalize before serialization, the same +way it MUST advance the frontier monotonically. It is load-bearing +correctness, not a storage optimization a client can opt out of. +`do_compact` remains available separately to mutate a device's own +`self.overrides` for local storage-GC purposes; it is no longer a +prerequisite for correct publication, because publication no longer +depends on prior local state having been compacted. + +**Proof obligation closed:** `exhaustive.py::test_published_merge_closure` +checks two ways — Thufir's exact witness pair +(`RegB(3,2,0)`@baseline-dead-50 join `RegB(1,2,100)`@clear-dead-100, +raw join is live `RegB(3,2,100)`) as a directed case under both tie +policies, and a general search over every pairwise join of a bounded +cube of 300 independently-dead published states (156 clear-wins + 144 +set-wins = 300 total across both tie policies), including a one-hop +relay republication step to cover delayed/multi-hop delivery (a relay +that receives one operand alone and republishes — re-canonicalizing — +before forwarding). The 45,074 ordered pairs checked comes from +156² + 144² + 2 directed witnesses. `mutation.py::mutant_m7` reverts +`publish_blob` to the pre-fix raw-serialization behavior and reproduces +Thufir's exact resurrection witness directly, confirming the new +invariant has teeth. + +## Candidate comparison + +### Convergence + +Both candidates converge under all tested delivery permutations (algebraic +property). +Candidate B achieves this with componentwise `max()` merge (a standard +state-based CRDT join). Candidate A uses a register with lexicographic +tuple comparison — also convergent, but the register requires a +client-identity tiebreak field. (Convergence for Candidate B is verified +by exhaustive BFS over all reachable states; I2–I4 and I6 are exercised +for Candidate B only — see invariant table.) + +### Legacy compatibility matrix + +| Scenario | A | B | +|----------|---|---| +| Upgraded publishes, legacy reads blob | Legacy drops `overrides` field | Legacy preserves `ov_*` sibling keys | +| Legacy rewrites same slot | **Overrides erased** (expected-witness confirmed) | Sibling keys survive sanitization | +| Upgraded reads legacy-rewritten blob | Override state lost | Override state intact | +| Legacy reads its own frontier | Inert (correct) | Inert (correct) | +| Legacy frontier advance past baseline | Cannot clear override (erased) | Stale set dominated (correct) | + +**Candidate A's legacy erasure is the decisive defect.** The desktop and +mobile parsers (`readStateFormat.ts:82-108`, `read_state_format.dart:100-141`) +reconstruct only `{v, client_id, contexts}`. A same-slot legacy rewrite +drops the top-level `overrides` field entirely and republishes without it. +There is no safe migration path: any user with a single legacy device +loses all manual-unread state on the next rewrite cycle. + +Candidate B's sibling keys (`ov_s:`, `ov_c:`, `ov_b:`) pass all legacy +validation gates — keys are <= 256 UTF-8 bytes, values are uint32 — +and round-trip through legacy rewrite unmodified. + +**Legacy carry-through simplification (documented divergence).** Row +"Legacy preserves `ov_*` sibling keys" is proven two different ways in +this model, and they are not the same claim: + +- `legacy_sanitize_blob` — the byte-sanitization function alone (drop + keys >256 UTF-8 bytes or non-uint32 values) — genuinely preserves + unknown keys as opaque pass-through, matching production + `sanitizeContexts`. `test_legacy_rewrite_b` (I7) exercises exactly + this: an upgraded device's blob is sanitized and received by a + *second upgraded* device; the sibling keys survive because + sanitization never touches keys it doesn't recognize. +- `DeviceB(is_legacy=True)` — the explorer's legacy *device* object used + in the multi-device BFS (`exhaustive.py`) — does **not** carry + through `ov_*` keys it receives. `receive_merge` parses them into a + local dict but the store step is gated on `not self.is_legacy` + (`model.py:268`), so a legacy device's own `publish_blob` only ever + republishes its own frontier keys, never sibling keys it received + from an upgraded peer. This is a deliberate model simplification, not + a claim about production: production's legacy client is a single + `sanitizeContexts` pass with no in-memory override model to gate on, + so it forwards unknown keys unchanged; the model's `DeviceB` needed an + explicit legacy/upgraded split to represent "does not understand or + act on overrides" for the BFS explorer's mark-unread/mark-read action + space, and that split was implemented as drop-on-receive rather than + store-opaque-and-forward. +- **Why this doesn't hide a defect:** every invariant that asserts + sibling-key survival through a legacy hop (I7) is checked via the + sanitize function directly, never via a `DeviceB(is_legacy=True)` + relay round-trip — the two paths are never conflated in a single + assertion. The BFS explorer's own legacy-device transitions are also + gated: `enabled_transitions` only enqueues `mark_unread`/`mark_read`/ + `compact` for a device `if not d.is_legacy` (`exhaustive.py:118-124`), + so a legacy device in the BFS never even attempts to act on overrides; + `do_mark_unread`/`do_mark_read` (`model.py:210-222`) additionally + carry an explicit `if self.is_legacy: return` no-op guard as + defense-in-depth for the same property. `do_compact` + (`model.py:227-236`) carries no such explicit guard — it is a no-op + for a legacy device only *transitively*, because `self.overrides` + is never populated for one (every write path into `self.overrides` + is already gated on `not self.is_legacy`), so `do_compact` finds + `self.overrides.get(ctx)` is always `None` and returns immediately. + Either way, the drop-on-receive simplification never + changes the BFS's own convergence or compaction verdicts (I2, I3, I5, + I5c, I5d) — those are computed only over upgraded devices' + `override_is_set`. The one place a real production legacy client + *does* matter for override survival — sanitizing an upgraded device's + own re-published blob — is I7's scope, and I7 uses the accurate + function. +- **Implication for implementation:** production's `sanitizeContexts` + pass-through behavior is correct and required; this note exists so a + future reader of `DeviceB.receive_merge` doesn't mistake the model's + drop-on-receive simplification for a claim that legacy relaying loses + override state in production — it doesn't, per the function-level + proof above. + +### Identity dependence + +- **A requires client_id** for the tiebreak field. After reinstall + (new `client_id`), the tiebreak changes. Convergence is preserved only + because the counter is strictly higher; a same-counter reinstall would + create an ambiguous merge. +- **B needs no client identity** — componentwise `max()` is + identity-free. Confirmed: reinstall with new `client_id` preserves + convergence. + +### Bytes per manually-unread context + +Sizes computed with realistic context IDs. Envelope cost +(`{"v":1,"client_id":"...","contexts":{}}`) is ~60 bytes and shared +across all contexts — amortized to near zero per context. + +| Context type | Context ID example | ID length | Live override keys (3) | Tombstone key (1) | +|--------------|-------------------|-----------|------------------------|--------------------| +| Channel | `b68cd7cb-6f8d-4641-b743-a7349eb4114b` | 36 | 138 bytes | 45 bytes | +| Message | `msg:` + 64-hex event ID | 68 | 234 bytes | 77 bytes | +| Thread | `thread:` + 64-hex event ID | 71 | 243 bytes | 80 bytes | + +Live-override bytes are unchanged by the reserved-namespace escaping +(below): every context ID Buzz actually generates (channel UUID, +`msg:hex64`, `thread:hex64`) is a no-op under `escape_context_key` — none +begin with `ov_` or `esc:` — so the escape marker costs 0 bytes in the +common case. Tombstone bytes are new in this revision: canonical +publication no longer serializes a dead register at 3 keys (see +"Compaction behavior" below and "Published-state merge closure" above) +but a single `ov_c:` key with the counter ceiling — this is now the +literal output of `publish_blob()` for any dead override, not merely +the output of the optional `do_compact` storage-GC step. + +Breakdown for channel context (worst real-world common case, live): +``` +"ov_s:b68cd7cb-6f8d-4641-b743-a7349eb4114b":1 → 44 chars +"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":0 → 44 chars +"ov_b:b68cd7cb-6f8d-4641-b743-a7349eb4114b":10 → 45 chars + total ≈ 138 bytes (+ 2 commas) +``` + +Tombstone floor for channel context (dead override after compaction): +``` +"ov_c:b68cd7cb-6f8d-4641-b743-a7349eb4114b":3 → 45 chars ≈ 45 bytes +``` + +Candidate A for comparison: `{"counter":1,"tiebreak":"dev0","op":"SET","baseline":10}` +≈ 56 bytes per context as a JSON object, plus the top-level `overrides` +field overhead. However, this is moot since A's top-level field is erased +by legacy clients. + +### Reserved key namespace + +NIP-RS v1 context IDs are arbitrary UTF-8 (spec `:89`, `:113-114`), so a +pre-existing opaque context could legitimately begin with `ov_s:`, +`ov_c:`, or `ov_b:` and, once flattened into the same `contexts` map, +be misparsed as a control key for a *different* context. + +**Reservation:** the 3-byte stem `ov_` and the escape marker `esc:` are +reserved at the spec-amendment level. A raw context ID that begins with +either is escaped on publish by prepending `esc:`, and unescaped on +receive by stripping exactly one leading `esc:` (`model.py: +escape_context_key`, `unescape_context_key`). This is a bijection, not +an idempotent no-op: a context literally named `esc:foo` escapes to +`esc:esc:foo` on the wire and unescapes back to exactly `esc:foo` on +receipt — the two operations are inverses, so no collision or data +loss occurs even for context IDs that already contain the marker. + +**Cost:** zero bytes for every context ID Buzz generates today (channel +UUID, `msg:hex64`, `thread:hex64` — none start with `ov_` or `esc:`). +Only a context ID that happens to start with the reserved stem pays the +4-byte `esc:` prefix. + +**Backward-compatibility limitation (Thufir's qualification — not a +collision-safe migration of existing data):** a context published +*unescaped* by a client that predates this amendment, and that happens +to start with `ov_` (e.g. an already-published, pre-existing +`ov_s:evil`-style context), is **not safely migrated** by this scheme. +Retroactive escaping cannot rewrite a blob the original publisher never +knew needed escaping — the codec protects contexts generated by +amendment-aware clients going forward, not history that predates the +amendment. This is a theoretical concern for the reasons in the +">256-byte key drop hazard" section: Buzz's own key shapes cannot +trigger it, and no legacy client is known to generate `ov_`-prefixed +context IDs. Documented as a residual, unsolved, backward-compatibility +gap — not modeled further — per the same practical-risk reasoning +already applied to the 256-byte hazard below. + +**Verified:** `exhaustive.py::test_reserved_namespace_collision` — a +context literally named `ov_s:evil` round-trips through publish/receive +as frontier state (not misparsed as an override), and a real override on +a *different* context in the same blob is unaffected. + +### Counter headroom (uint32) + +Each counter (S, C) is a uint32: 2^32 - 1 = 4,294,967,295. At one +toggle per second, ~136 years. No practical concern for manual +right-click actions. + +### >256-byte key drop hazard + +Legacy `sanitizeContexts` drops any key with `len(key.encode('utf-8')) > 256`. +Adding the `ov_s:` prefix (5 bytes) to a context key creates a key of +`len(context_id) + 5` bytes. If the original context key is at or near +the 256-byte limit, the prefixed override key exceeds it and is silently +dropped by legacy sanitization. + +In practice, context keys are UUIDs (36 bytes), hex event IDs (64-68 bytes), +or thread IDs (71 bytes) — all well under 256 bytes. The longest common +override key (`ov_b:thread:` + 64-hex = 76 bytes) has 180 bytes of +headroom. This hazard is theoretical but should be documented in the spec. + +### 10,000-key validation limit + +Legacy `isValidBlob` rejects blobs with >10,000 context keys. Live +override keys consume 3 entries per overridden context; a compacted +(tombstoned) override consumes 1: + +| Overridden contexts | Live override keys | Typical frontier keys | Total | Headroom | +|--------------------|---------------------|-----------------------|-------|----------| +| 50 | 150 | ~500 | 650 | 93.5% | +| 100 | 300 | ~1,000 | 1,300 | 87% | +| 500 | 1,500 | ~2,000 | 3,500 | 65% | +| 3,000 | 9,000 | ~1,000 | 10,000 | 0% (limit) | + +The 32 KiB byte budget is the binding constraint long before key count. + +### Compaction behavior (tombstone-floor, policy-dependent) + +**Revision note:** the prior "compacts to zero" design (delete-on- +dominance: a dead register was dropped entirely, 0 keys) is retracted. +Thufir's pass-3 review found a stale-replay resurrection: dropping all +`(S,C)` state made counters reusable, so a new local set/clear pair +restarting from `S=0,C=0` could be dominated by a delayed stale peer +snapshot on replay (`RegB(3,0,10)` → compact → `None` → local +set+clear → `RegB(1,2,20)` → stale replay merges in → `RegB(3,2,20)`, +`S>C`, resurrected). Fixed by a tombstone floor: any register with +recorded activity (S>0 or C>0) is *never* fully deleted — dead state +compacts to `RegB(0, max(S,C), 0)` instead of `None`. Only a virgin +register (never set, S==0 and C==0) has no ceiling to protect and +compacts to `None`. + +**The compaction rule is now uniform across the dead cases — the +per-branch table collapses to a single test:** + +| Condition | Clear-wins | Set-wins | +|-----------|-----------|----------| +| `override_set_b(reg)` is True (live) | Do not compact | Do not compact | +| `override_set_b(reg)` is False and `S>0 or C>0` (dead, ever-active) | Compact to tombstone floor `RegB(0, max(S,C), 0)` | Compact to tombstone floor (same) | +| `S == 0, C == 0` (virgin, never set) | Drop entirely (`None`) | Drop entirely (same) | + +Because `override_set_b` is already policy-aware, "live" vs. "dead" +differs by policy exactly where it did before (`S == C, S > 0` is dead +under clear-wins, live under set-wins) — the tombstone floor rule itself +does not need to branch on policy; `compact_b` calls `override_set_b` +once and only tombstones the false branch. + +Under clear-wins, a dead override compacts to the ~45-byte tombstone +(one `ov_c:` key, channel context) — **not** to zero, because `C` must +persist as the reuse-blocking ceiling. Under set-wins, `S == C` overrides +remain live and are never compacted (3 keys, ~138 bytes for channel +contexts) — unchanged from the prior revision. + +**Proof obligation closed (same-device replay):** +`exhaustive.py::test_deep_history_compaction` (672-point parameter +cube) and `test_tombstone_stale_merge_direct` verify no resurrection +and no loss of a genuinely-live override across the compact → +new-action → delayed-stale-delivery shape, for both tie policies. +`mutation.py::mutant_m4` +reverts to the old delete-on-dominance rule and reproduces the exact +resurrection witness (`final_reg=RegB(s=3, c=2, b=20)`, +`override_is_set=True`) — confirming the suite would have caught the +defect this round was opened to fix. + +**Proof obligation closed (cross-device transparency, requalified — +suppress-only, not zero-divergence):** +`exhaustive.py::test_cross_device_compaction_suppression` (312-point +cube: stale ancestor `(S,C,B)` × post-compaction frontier × 4 +fresh-frontier values on the receiving device × 2 tie policies) proves +every divergence between "receive the tombstone" and "receive the +uncompacted ancestor" is a suppression of an unrelated device's live +set — never a resurrection — and that every suppression recovers with +one more local mark-unread and stays recovered after re-receiving the +same tombstone. `test_tombstone_merge_monotonic` proves the direction +structurally (not just over the bounded cube): merging in a tombstone +`RegB(0, k, 0)` for any ceiling `k` can only raise the receiving +register's `C`, never its `S` or `B`, so it can only weaken — never +strengthen — the receiving register's live/dead standing under +`override_set_b`. Together these close the compaction-safety proof +obligation to exactly what it can honestly claim: no resurrection ever, +one-shot suppression is a known and recoverable false-negative risk +inherent to the clear-wins/tombstone design, not an unbounded +correctness gap. + +### GC/tombstone behavior + +**Override keys with `ov_` prefix (legacy prune):** Legacy +`pruneStaleContexts` only drops `msg:`/`thread:`-prefixed keys past the +7-day horizon. Unknown-prefix keys (including `ov_*`) are kept forever: + +- **Permanent tombstones:** every override that is ever compacted while + dead leaves a permanent `ov_c:` key (~45 bytes, channel context) — this + is no longer a "harmless, can shrink to zero" cost; it is a durable + floor kept forever to block stale-replay resurrection. This is the + direct storage consequence of fixing the CRITICAL above and must be + budgeted, not treated as free. +- **Live overrides:** an override still live (per `override_set_b`) + keeps all 3 keys (~138 bytes, channel context) until it becomes dead + and is compacted down to the tombstone. + +**Alternative: nesting under `msg:`/`thread:` prefixes** — confirmed +**state-loss hazard**. Legacy prune would delete overrides at the 7-day +horizon, silently losing active unread markers. Rejected. + +### Legacy trim interaction + +Legacy `trimContextsToBudget` evicts only `msg:`/`thread:` keys. +Override `ov_*` keys (including tombstones) are never evicted. Budget +analysis by context type, worst case (all overrides still live, 3 keys +each — the tombstone floor only ever *reduces* this cost): + +| Overridden contexts | Context type | Live override bytes | With ~10 KiB frontiers | Fits 32 KiB? | +|--------------------|-------------|----------------|----------------------|-------------| +| 50 | Channel (UUID) | ~6.9 KiB | ~16.9 KiB | Yes | +| 100 | Channel (UUID) | ~13.8 KiB | ~23.8 KiB | Yes | +| 150 | Channel (UUID) | ~20.7 KiB | ~30.7 KiB | Marginal | +| 50 | Message (hex64) | ~11.9 KiB | ~21.9 KiB | Yes | +| 100 | Message (hex64) | ~23.7 KiB | ~33.7 KiB | **No** | + +At the 100-override cap with every override compacted to its tombstone +floor instead: ~4.5 KiB (channel contexts, 100 × 45 bytes) — well +within budget alongside a full frontier set. The permanent-tombstone +floor from the CRITICAL fix costs storage but is bounded and small; it +does not change the 32 KiB conclusion below. + +**Mitigation:** Upgraded clients should compact aggressively (any dead +override, not just baseline-dominated ones) and enforce a cap on active +override count. A cap of 100 channel-context overrides keeps *live* +override budget under ~14 KiB and *tombstoned* budget under ~4.5 KiB, +both within the 32 KiB limit alongside a full frontier set. + +### Tie policy evidence: clear-wins vs set-wins + +Both tie policies pass all invariants. The choice is a product-semantics +decision: + +- **Clear-wins (S == C → read):** If two devices concurrently set and + clear the same context, the result is "read." Conservative — no + spurious unread badges. Matches the "I already read this" signal being + more definitive than the "remind me" signal. Compaction advantage: + `S == C` states are compactable. +- **Set-wins (S == C → unread):** Concurrent set and clear results in + "unread." Preserves the reminder intent. Risk: a user who reads on one + device while another has a stale mark-unread gets a persistent badge + they can't clear without an explicit action. Compaction disadvantage: + `S == C` states are live and cannot be compacted. + +**Recommendation:** Clear-wins. A false negative (missing badge) is +recovered by re-marking unread. A false positive (badge that won't clear) +is more frustrating. This matches Slack's behavior: reading anywhere +clears everywhere. The compaction advantage further favors clear-wins. + +**Pre-existing false-negative risk (independent of compaction).** Under +clear-wins, a stale explicit clear (`RegB(0,1,0)`, no compaction +involved) merging into a device with a fresh concurrent set +(`RegB(1,0,30)`) already produces `RegB(1,1,30)`, tied, suppressed — +verified directly by evaluating `merge_reg_b`/`override_set_b` on those +two registers with no `compact_b` call anywhere in the path. The +cross-device tombstone-suppression finding (I5d, "Compaction behavior" +above) is the same tie shape reached via a different route: a +baseline-dominated *dead set* (never explicitly cleared) that gets +compacted to a `C`-ceiling tombstone, which is then globally comparable +in a way its pre-compaction, frontier-relative death was not. Compaction +widens the set of histories that can reach the tie, but clear-wins +already accepted this one-shot, re-mark-recoverable false-negative shape +as its stated tradeoff. + +### Multi-slot union + +Production splits blobs across up to 8 slots (`READ_STATE_MAX_SLOTS`). +`mergeReadStateEvents` merges all slots with per-context `max()`. Override +sibling keys are individual context entries and follow the same merge path. + +**Atomic slot-grouping rule (spec-amendment requirement):** a context's +frontier entry and ALL of its `ov_*` sibling entries MUST travel in the +same slot, including during slot growth/rebalancing. This is the transport +half of the same closure property as mandatory canonical publication: + +- Without it, an observer holding only a slot containing `ov_s:ctx` (but + not `ov_b:ctx`) reconstructs `RegB(s=1, c=0, b=0)` — baseline-dead at + any nonzero frontier — and canonically publishes tombstone `RegB(0,1,0)`. + After full eventual delivery of all original slots plus that transient + tombstone, the merged result is `RegB(s=1, c=1, b=10)` — dead under + clear-wins — permanently suppressing a live override. +- With the rule, a receiver always sees either the complete register group + or none of it; partial reconstruction is structurally impossible from a + compliant publisher's output. + +Implementation: amend `splitContextsIntoBudgetedSlots` to round-robin +per-context groups (frontier key + all `ov_*` sibling keys for that context) +rather than per-entry. `DeviceB.split_blob_into_slots` in `model.py` models +this correctly. + +**Unescape-before-group rule (corollary — spec-amendment requirement):** +When grouping context entries, a frontier wire key MUST be unescaped to its +raw logical context ID before being used as the group key. A raw context ID +starting with a reserved prefix (e.g. `ov_s:evil`) escapes to +`esc:ov_s:evil` as its frontier wire key, while its `ov_*` siblings are +keyed by the raw suffix (`ov_s:evil`). Without unescaping the frontier key +before grouping, these resolve to different groups and the register splits +across slots — reproducing the same partial-reconstruction poison across +publication cycles via old/new slot-coordinate mixtures. Fix: derive group +identity via `unescape_context_key(wire_key)` for frontier keys. +`mutation.py::mutant_m9` reverts to escaped-key grouping and confirms +`test_escaped_context_slot_grouping` catches the witness. + +`mutation.py::mutant_m8` reverts to per-entry splitting (M8's split puts +frontier+`ov_s:` in slot 0 and `ov_b:`+`ov_c:` in slot 1) and confirms +`test_interleaved_delivery_grouping` catches Thufir's exact witness. + +This rule carries the same normative weight as mandatory canonical publication: +both are protocol requirements for any client implementing this override layer, +not optional optimizations. + +Confirmed: splitting a published blob across 2 grouped slots and delivering +each separately produces the same final override and frontier state as +delivering the full blob, regardless of delivery order. Interleaved-delivery +test (`test_interleaved_delivery_grouping`) additionally verifies that +receive-one-slot → re-publish → receive-rest permutations, including delayed +transient delivery to a third observer, preserve the live override verdict. + +## Mutation harness + +9 mutants, all caught with recorded counterexamples: + +| Mutant | Rule dropped | Counterexample | +|--------|-------------|----------------| +| M1 | Baseline dominance check | `RegB(1,0,10)` at frontier=100: correct=inactive, mutant=active (stale set persists) | +| M2 | `max(S,C)+1` counter bump | After set→set→clear: correct `RegB(2,3,10)` (clear wins), mutant `RegB(2,1,10)` (set persists) | +| M3 | Tie policy | `RegB(1,1,10)` at frontier=10: clear-wins=False, set-wins=True | +| M4 | Tombstone-floor compaction (delete-on-dominance revert) | `RegB(3,0,10)` at frontier=20 compacts to `None` (vs. tombstone `RegB(0,3,0)`); local set+clear reuses counters from zero; delayed stale replay resurrects — `final_reg=RegB(s=3,c=2,b=20)`, `override_is_set=True` (reproduces Thufir's pass-3 CRITICAL) | +| M5 | uint32 value range | Value 4,294,967,296 rejected by legacy sanitization | +| M6 | Componentwise-max merge | LWW delivery-order-dependent: convergence breaks under permutation | +| M7 | Canonical publication (raw register serialization) | `RegB(3,2,0)`@frontier-50 join `RegB(1,2,100)`@frontier-100 = live `RegB(3,2,100)` (reproduces Thufir's pass-1/2 CRITICAL dead+dead resurrection) | +| M8 | Atomic slot-grouping rule (per-entry split) | Live `RegB(1,0,10)` at frontier=10 split as `{frontier+ov_s:}` / `{ov_b:+ov_c:}`; partial observer reconstructs `RegB(1,0,0)`, publishes tombstone `RegB(0,1,0)`; final merge = `RegB(1,1,10)` → inactive (reproduces Thufir's pass-2/2 CRITICAL transport witness) | +| M9 | Unescape-before-group rule (escaped-key grouping) | Live override on raw ctx `ov_s:evil` (frontier wire key `esc:ov_s:evil`); escaped-key grouping splits frontier from `ov_*` siblings; old/new slot-coordinate mixture → `RegB(1,0,0)` → tombstone `RegB(0,1,0)` → final merge = `RegB(1,1,10)` → inactive (reproduces Thufir's round-2 CRITICAL) | + +Each mutant is injected into the model via DeviceB subclass (M1, M2, M4, +M6, M7, M8, M9) or direct function evaluation (M3, M5), then the applicable +invariant suite is rerun. M4 reverts to the pre-fix delete-on-dominance +compaction rule and directly reproduces Thufir's pass-3 CRITICAL resurrection +witness — the exact `RegB(3,0,10)` → `None` → counter-reuse → stale +replay → `RegB(3,2,20)`,`override_is_set=True` sequence — with a +fallback to the directed deep-history cube (`test_deep_history_compaction`) +if the hand-built scenario doesn't trigger under a given tie policy. M7 +reverts `publish_blob` to raw serialization and reproduces Thufir's pass-1/2 +CRITICAL dead+dead resurrection. M8 reverts `split_blob_into_slots` to +per-entry assignment (frontier+`ov_s:` / `ov_b:`+`ov_c:`) and reproduces +Thufir's pass-2/2 CRITICAL transport witness via `test_interleaved_delivery_grouping`. +M9 reverts `split_blob_into_slots` to escaped-key grouping (groups frontier by +its wire key instead of its unescaped logical ID) and reproduces Thufir's +round-2 CRITICAL for escaped contexts via `test_escaped_context_slot_grouping`. + +## Recommendation + +**Candidate B (two grow-only counters + baseline) with clear-wins tie +policy.** + +Evidence: + +1. **Legacy safety:** B's sibling keys survive legacy rewrite; A's + top-level field is erased. Hard blocker for A — no migration path + tolerates a single legacy device. +2. **Identity-free:** B needs no client_id for correctness; A's + tiebreak creates a reinstall fragility. +3. **CRDT properties:** Candidate B passes all merge invariants (I2–I8) in + the exhaustive model. Candidate A's join is also correct algebraically + (I1, I9), but I2–I4 and I6 are not exercised for A — A is dead on I7 + regardless. B's componentwise max is simpler and more standard. +4. **Bytes:** B at 3 live keys costs 138 bytes/context (channel UUID) to + 243 bytes/context (thread hex64); a dead override compacts to a single + ~45-80 byte tombstone key instead. Cap of 100 overrides stays within + 32 KiB budget for both live and tombstoned cases. +5. **Compaction:** B supports safe policy-aware compaction — no + resurrection, ever (proved structurally, not just over a bounded + cube). Clear-wins allows compacting `S == C` states (set-wins does + not). Cross-device delivery of a tombstone can one-shot suppress an + unrelated device's concurrent fresh set whose counters are at or + below the tombstone's ceiling; this is recoverable by re-marking and + is the same false-negative shape clear-wins already accepts for a + stale explicit clear with no compaction involved (see "Tie policy + evidence"). +6. **Tie policy:** Clear-wins avoids persistent false-positive badges + and enables more aggressive compaction. + +## Honest limits + +- The model enumerates bounded abstract operations, not real encrypted + NIP-59 payloads or relay replacement semantics. +- Counter values in the general BFS explorer are bounded by its exploration + depth (max ~4 via BFS depth 4); the directed deep-history cube + (`test_deep_history_compaction`) reaches counter values up to the stale + parameter range (0-3) plus post-compaction action sequences, covering the + ~9-transition witness the BFS explorer cannot structurally reach. Real + uint32 overflow/wrap is tested only via the legacy sanitization mutant (M5). +- The BFS explorer (I5/I5c) checks compaction safety over reachable + multi-device histories up to depth 4, but its own terminal-state + compaction check (`check_compaction_safety`) only merges a device's + compacted register with its *own* pre-compaction snapshot — it does + not, by construction, exercise an unrelated device's independently- + live concurrent register. `test_cross_device_compaction_suppression` + (I5d) covers that shape directly but over a hand-parameterized cube, + not the full BFS state space; the accompanying + `test_tombstone_merge_monotonic` lemma is what extends the + no-resurrection guarantee beyond the cube's specific points. +- Two contexts are modeled. Production users may have hundreds of contexts, + but the CRDT properties are per-context — cross-context interactions are + limited to the shared byte budget (tested via trim/prune interaction). +- Multi-slot behavior is confirmed via split+merge convergence test, and + the atomic slot-grouping rule is modeled by `DeviceB.split_blob_into_slots` + (including the escaped-context identity fix — `split_blob_into_slots` + unescapes frontier keys before grouping). The production TypeScript + implementation (`splitContextsIntoBudgetedSlots`) is NOT modeled — only + the abstract grouping property is verified here. Implementation-level + testing is still needed for slot placement, slot rebalancing, and the + production d-tag coordinate assignment. +- The model assumes eventual delivery (all blobs eventually reach all + devices). Permanent message loss is not modeled. +- Byte sizes are computed from JSON serialization of realistic key names. + Actual encrypted blob overhead (NIP-59 envelope, relay metadata) adds + to the total but does not affect the 32 KiB plaintext budget. diff --git a/docs/formal/nip-rs-unread/exhaustive.py b/docs/formal/nip-rs-unread/exhaustive.py new file mode 100644 index 0000000000..82d54c6b43 --- /dev/null +++ b/docs/formal/nip-rs-unread/exhaustive.py @@ -0,0 +1,1486 @@ +"""Bounded transition-system explorer for NIP-RS manual-unread candidates. + +BFS over canonical global states. At each depth, enabled transitions are +local actions and message deliveries, interleaved — not phased. + +Universe: 2 upgraded devices + 1 legacy device, 2 contexts. +Transitions: mark_unread, mark_read (with frontier advance), + advance_frontier, compact, reinstall, deliver (including + duplicate/replay). Legacy rewrite semantics are exercised through the + deliver path (legacy_sanitize_and_publish), not as a separate + transition — a legacy device never mutates its own state outside + delivery, so a dedicated no-op transition added nothing (see NOTE.md). + +Invariants: + I1 merge_reg_b associative/commutative/idempotent + I2 convergence: all delivery orders -> identical override verdict + I3 no frontier regression + I4 concurrent set/clear winner stable (order-independent, ancestor-independent) + I5 compaction: no loss of live set, no resurrection of dead clear, + survives merge with stale pre-compaction state + I5c directed deep-history: compact -> new local actions (counter reuse) + -> delayed stale delivery does not resurrect a dead override or + lose a genuinely-live one. Scope: the compacting device's own + pre-compaction ancestor (or an exact copy of it) replayed back to + that same device. + I5d cross-device compaction transparency (requalified, NOT + zero-divergence): a tombstone's counter ceiling can one-shot + suppress an unrelated device's concurrent fresh set with no + resurrection, and the suppression is always recoverable by one + more local action. Proven suppress-only direction via a bounded + witness cube plus a structural monotonicity argument. + I6 replay harmless + I7 legacy rewrite: B sibling keys survive / A overrides erased (witness) + I8 bounded key growth per context + I9 DeviceA post-receive counter absorption +""" +from itertools import permutations +from copy import deepcopy +from model import ( + RegB, merge_reg_b, override_set_b, compact_b, + RegA, merge_reg_a, + DeviceB, DeviceA, + SET, CLEAR, + legacy_prune, legacy_trim, legacy_sanitize_blob, + escape_context_key, unescape_context_key, ESCAPE_PREFIX, +) + +CONTEXTS = ("c0", "c1") +FRONTIER_VALS = (10, 20) + + +# --------------------------------------------------------------------------- +# I1: algebraic properties +# --------------------------------------------------------------------------- + +def test_merge_algebra_b(): + vals = [0, 1, 2, 3] + regs = [RegB(s, c, b) for s in vals for c in vals for b in vals] + violations = [] + for a in regs: + if merge_reg_b(a, a) != a: + violations.append(("idempotent", a)) + for a in regs: + for b in regs: + if merge_reg_b(a, b) != merge_reg_b(b, a): + violations.append(("commutative", a, b)) + for a in regs: + for b in regs: + for c in regs: + if merge_reg_b(merge_reg_b(a, b), c) != merge_reg_b(a, merge_reg_b(b, c)): + violations.append(("associative", a, b, c)) + return violations + + +def test_merge_algebra_a(): + vals = [0, 1, 2] + tiebreaks = ["a", "b"] + ops = [SET, CLEAR] + baselines = [0, 10] + regs = [RegA(ct, t, o, bl) + for ct in vals for t in tiebreaks for o in ops for bl in baselines] + violations = [] + for tie_op in [CLEAR, SET]: + for a in regs: + if merge_reg_a(a, a, tie_op) != a: + violations.append(("idempotent", tie_op, a)) + for a in regs: + for b in regs: + if merge_reg_a(a, b, tie_op) != merge_reg_a(b, a, tie_op): + violations.append(("commutative", tie_op, a, b)) + for a in regs: + for b in regs: + for c in regs: + ab_c = merge_reg_a(merge_reg_a(a, b, tie_op), c, tie_op) + a_bc = merge_reg_a(a, merge_reg_a(b, c, tie_op), tie_op) + if ab_c != a_bc: + violations.append(("associative", tie_op, a, b, c)) + return violations + + +# --------------------------------------------------------------------------- +# BFS state explorer — Candidate B +# --------------------------------------------------------------------------- + +def next_frontier(device, ctx): + cur = device.effective_frontier(ctx) + for fv in FRONTIER_VALS: + if fv > cur: + return fv + return None + + +def enabled_transitions(devices, tie_policy): + """Generate (kind, args) tuples for all enabled transitions.""" + trans = [] + for di, d in enumerate(devices): + for ctx in CONTEXTS: + if not d.is_legacy: + trans.append(("mark_unread", di, ctx)) + fv = next_frontier(d, ctx) + if fv is not None: + trans.append(("mark_read", di, ctx, fv)) + if ctx in d.overrides: + trans.append(("compact", di, ctx)) + fv = next_frontier(d, ctx) + if fv is not None: + trans.append(("advance", di, ctx, fv)) + if not d.is_legacy: + trans.append(("reinstall", di)) + for si in range(len(devices)): + for di in range(len(devices)): + if si != di: + trans.append(("deliver", si, di)) + return trans + + +def apply_transition(devices, t, tie_policy): + kind = t[0] + if kind == "mark_unread": + devices[t[1]].do_mark_unread(t[2]) + elif kind == "mark_read": + devices[t[1]].do_mark_read(t[2], t[3]) + elif kind == "advance": + devices[t[1]].do_advance_frontier(t[2], t[3]) + elif kind == "compact": + devices[t[1]].do_compact(t[2], tie_policy) + elif kind == "reinstall": + devices[t[1]].do_reinstall() + elif kind == "deliver": + src = devices[t[1]] + dst = devices[t[2]] + if src.is_legacy: + blob = src.legacy_sanitize_and_publish(tie_policy) + else: + blob = src.publish_blob(tie_policy) + dst.receive_merge(blob) + + +def state_sig(devices, tie_policy): + return tuple(d.state_key(CONTEXTS, tie_policy) for d in devices) + + +def check_convergence(devices, tie_policy, trace, violations): + """Publish all blobs, deliver in every order, check upgraded devices + converge on override_is_set for each context. + + Tests with latest_ts=5 (below all frontiers) so the override is the + sole unread source — no masking by natural unread. + """ + blobs = [] + for d in devices: + if d.is_legacy: + blobs.append(d.legacy_sanitize_and_publish(tie_policy)) + else: + blobs.append(d.publish_blob(tie_policy)) + + verdicts_per_order = [] + for perm in permutations(range(len(blobs))): + receivers = deepcopy(devices) + for idx in perm: + for r in receivers: + r.receive_merge(blobs[idx]) + per_device = [] + for r in receivers: + if not r.is_legacy: + per_device.append( + tuple(r.override_is_set(ctx, tie_policy) for ctx in CONTEXTS) + ) + verdicts_per_order.append(tuple(per_device)) + + if len(set(verdicts_per_order)) > 1: + violations.append(("I2-convergence", trace, set(verdicts_per_order))) + + +def check_compaction_safety(devices, tie_policy, trace, violations): + """For each upgraded device with overrides: + 1. Check override_is_set directly (not via verdict/latest_ts). + 2. Compact and verify override_is_set unchanged. + 3. Merge compacted state with stale pre-compaction state in both orders. + Verify no resurrection and no loss. + """ + for di, d in enumerate(devices): + if d.is_legacy: + continue + for ctx in CONTEXTS: + reg = d.overrides.get(ctx) + if reg is None: + continue + front = d.effective_frontier(ctx) + ov_before = d._override_set(reg, front, tie_policy) + compacted = d._compact(reg, front, tie_policy) + ov_after = d._override_set(compacted, front, tie_policy) if compacted else False + + if ov_before and not ov_after: + violations.append(( + "I5-compaction-lost-set", trace, di, ctx, + reg, compacted, front, tie_policy + )) + if not ov_before and ov_after: + violations.append(( + "I5-compaction-resurrection", trace, di, ctx, + reg, compacted, front, tie_policy + )) + + if compacted is not None: + for merged in [merge_reg_b(compacted, reg), merge_reg_b(reg, compacted)]: + ov_merged = d._override_set(merged, front, tie_policy) + if not ov_before and ov_merged: + violations.append(( + "I5-compaction-merge-resurrection", trace, di, ctx, + reg, compacted, merged + )) + + +def explore_b(max_depth=4, tie_policy=CLEAR, device_cls=DeviceB): + """BFS over all reachable global states up to max_depth. + + Returns (states_explored, violations). + Accepts device_cls for mutation testing via subclassing. + """ + def make_devices(): + return [ + device_cls("d0", is_legacy=False), + device_cls("d1", is_legacy=False), + device_cls("d2", is_legacy=True), + ] + + violations = [] + seen = set() + states_explored = 0 + queue = [(make_devices(), [])] + + while queue: + devices, trace = queue.pop(0) + sig = state_sig(devices, tie_policy) + if sig in seen: + continue + seen.add(sig) + states_explored += 1 + + for di, d in enumerate(devices): + for ctx in CONTEXTS: + prev_front = d.effective_frontier(ctx) + if prev_front < 0: + violations.append(("I3-frontier-negative", trace, di, ctx)) + + if len(trace) >= max_depth: + check_convergence(devices, tie_policy, trace, violations) + check_compaction_safety(devices, tie_policy, trace, violations) + continue + + for t in enabled_transitions(devices, tie_policy): + new_devs = deepcopy(devices) + fronts_before = { + (di, ctx): d.effective_frontier(ctx) + for di, d in enumerate(new_devs) for ctx in CONTEXTS + } + apply_transition(new_devs, t, tie_policy) + + # Reinstall intentionally wipes local state; frontier regression + # is only invalid during merge/delivery/compaction/advance. + if t[0] != "reinstall": + for (di, ctx), fb in fronts_before.items(): + fa = new_devs[di].effective_frontier(ctx) + if fa < fb: + violations.append(("I3-frontier-regression", trace + [t], di, ctx, fb, fa)) + + queue.append((new_devs, trace + [t])) + + return states_explored, violations + + +# --------------------------------------------------------------------------- +# I4: concurrent set/clear winner stable +# --------------------------------------------------------------------------- + +def test_concurrent_stability(device_cls=DeviceB): + """Two devices concurrently set and clear from every possible ancestor state. + The winner must be the same regardless of delivery order AND ancestor state.""" + violations = [] + for tie_policy in [CLEAR, SET]: + for pre_s, pre_c in [(0, 0), (1, 0), (0, 1), (2, 1), (1, 2), (1, 1)]: + for front in [0, 10]: + for ctx in CONTEXTS: + ancestor = RegB(s=pre_s, c=pre_c, b=front) + + d0 = device_cls("d0") + d0.frontier[ctx] = front + d0.overrides[ctx] = deepcopy(ancestor) + d1 = device_cls("d1") + d1.frontier[ctx] = front + d1.overrides[ctx] = deepcopy(ancestor) + + d0.do_mark_unread(ctx) + d1.do_mark_read(ctx, front + 10) + + blob0 = d0.publish_blob(tie_policy) + blob1 = d1.publish_blob(tie_policy) + + verdicts = set() + for first, second in [(blob0, blob1), (blob1, blob0)]: + r = device_cls("recv") + r.frontier[ctx] = front + r.overrides[ctx] = deepcopy(ancestor) + r.receive_merge(first) + r.receive_merge(second) + verdicts.add(r.override_is_set(ctx, tie_policy)) + + if len(verdicts) > 1: + violations.append(( + "I4-unstable", tie_policy, ctx, + pre_s, pre_c, front + )) + return violations + + +# --------------------------------------------------------------------------- +# I5: direct compaction register-level check (all register values x policies) +# --------------------------------------------------------------------------- + +def test_compaction_register_exhaustive(): + """Exhaustive check over bounded register cube and frontier values. + Tests override_is_set directly — no latest_ts masking.""" + violations = [] + vals = [0, 1, 2, 3] + frontiers = [0, 10, 20] + for tie_policy in [CLEAR, SET]: + for s in vals: + for c in vals: + for b in frontiers: + for fv in frontiers: + reg = RegB(s=s, c=c, b=b) + ov_before = override_set_b(reg, fv, tie_policy) + compacted = compact_b(reg, fv, tie_policy) + ov_after = (override_set_b(compacted, fv, tie_policy) + if compacted else False) + + if ov_before and not ov_after: + violations.append(( + "loss", tie_policy, reg, fv, compacted + )) + if not ov_before and ov_after: + violations.append(( + "resurrection", tie_policy, reg, fv, compacted + )) + + if compacted is not None: + merged_fwd = merge_reg_b(compacted, reg) + merged_rev = merge_reg_b(reg, compacted) + for label, merged in [("fwd", merged_fwd), ("rev", merged_rev)]: + ov_merged = override_set_b(merged, fv, tie_policy) + if not ov_before and ov_merged: + violations.append(( + f"merge-resurrection-{label}", + tie_policy, reg, fv, compacted, merged + )) + return violations + + +# --------------------------------------------------------------------------- +# I5c: directed deep-history — compact -> new actions (counter reuse) -> +# delayed stale delivery (including split across two slots) +# --------------------------------------------------------------------------- + +def _apply_action_seq(dev, ctx, seq, ts): + for a in seq: + if a == "set": + dev.do_mark_unread(ctx) + else: + dev.do_mark_read(ctx, ts) + + +def _ancestor_ctx_dict(ctx, reg): + return {f"ov_s:{ctx}": reg.s, f"ov_c:{ctx}": reg.c, f"ov_b:{ctx}": reg.b} + + +_DEEP_HISTORY_ACTION_SEQS = [ + (), ("set",), ("clear",), ("set", "clear"), ("clear", "set"), + ("set", "set"), ("clear", "clear"), +] +# One delivery shape: single unsplit blob. The prior "split_fwd"/"split_rev" +# shapes are no longer distinct — with the atomic-grouping rule a single- +# context blob's compliant split puts the whole group in one slot and the +# other empty, making split_fwd and split_rev semantically identical to +# single. Keeping only one shape avoids 2/3 duplicate executions (2,016 → +# 672 meaningful points) while losing zero register-level coverage. +_DEEP_HISTORY_DELIVERY_SHAPES = ("single",) + + +def test_deep_history_compaction(device_cls=DeviceB): + """Directed check over the exact shape a depth-4 BFS structurally + cannot reach (~9 transitions): compact -> new local set/clear actions + (counter reuse against the tombstone floor) -> delayed delivery of + the pre-compaction stale ancestor, including split across 2 slots. + + Oracle: compaction is a storage optimization and must never change + the semantic outcome. A reference device that never compacts, given + the identical ancestor / frontier advance / action sequence / late + ancestor delivery, must reach the same override_is_set verdict as + the compacting device. This directly targets Thufir's counterexample + (RegB(3,0,10) -> None under delete-on-dominance -> counter reuse -> + RegB(3,2,20) resurrection) and requires the tombstone floor from + compact_b to hold under it. + + Returns (cube_size, violations). + """ + violations = [] + cube_size = 0 + ctx = "c0" + stale_vals = (0, 1, 2, 3) + baselines = (0, 10) + post_frontiers = (10, 20) + + for s0 in stale_vals: + for c0 in stale_vals: + for b0 in baselines: + for f1 in post_frontiers: + if f1 <= b0: + continue # not a dominance/compaction scenario + ancestor = RegB(s=s0, c=c0, b=b0) + ancestor_blob = _ancestor_ctx_dict(ctx, ancestor) + for seq in _DEEP_HISTORY_ACTION_SEQS: + for tie_policy in (CLEAR, SET): + for shape in _DEEP_HISTORY_DELIVERY_SHAPES: + cube_size += 1 + + dev = device_cls("d0") + dev.frontier[ctx] = b0 + dev.overrides[ctx] = ancestor + dev.do_advance_frontier(ctx, f1) + dev.do_compact(ctx, tie_policy) + _apply_action_seq(dev, ctx, seq, f1) + + if shape == "single": + dev.receive_merge({"contexts": dict(ancestor_blob)}) + ov_after = dev.override_is_set(ctx, tie_policy) + + ref = device_cls("ref") + ref.frontier[ctx] = b0 + ref.overrides[ctx] = ancestor + ref.do_advance_frontier(ctx, f1) + _apply_action_seq(ref, ctx, seq, f1) + ref.receive_merge({"contexts": dict(ancestor_blob)}) + ov_ref = ref.override_is_set(ctx, tie_policy) + + if ov_after != ov_ref: + violations.append(( + "I5c-deep-history-divergence", tie_policy, shape, + ancestor, f1, seq, + f"compacted_path={ov_after}", f"reference={ov_ref}", + )) + return cube_size, violations + + +def test_tombstone_stale_merge_direct(): + """Tombstone floor merged directly with its own pre-compaction stale + ancestor (no intervening local actions) must not resurrect and must + not exceed the ancestor's own verdict.""" + violations = [] + vals = (0, 1, 2, 3) + frontiers = (0, 10, 20) + for tie_policy in (CLEAR, SET): + for s in vals: + for c in vals: + for b in frontiers: + for fv in frontiers: + if fv <= b: + continue + reg = RegB(s=s, c=c, b=b) + compacted = compact_b(reg, fv, tie_policy) + if compacted is None: + continue # virgin register: nothing to tombstone + ov_before = override_set_b(reg, fv, tie_policy) + for merged in (merge_reg_b(compacted, reg), merge_reg_b(reg, compacted)): + ov_merged = override_set_b(merged, fv, tie_policy) + if not ov_before and ov_merged: + violations.append(( + "tombstone-stale-merge-resurrection", + tie_policy, reg, fv, compacted, merged, + )) + return violations + + +# --------------------------------------------------------------------------- +# I5d: cross-device compaction transparency (requalified — suppress-only, +# NOT zero-divergence) + re-mark recovery +# --------------------------------------------------------------------------- + +def test_tombstone_merge_monotonic(): + """Structural lemma: merging in ANY tombstone RegB(0, k, 0) is a + monotonically non-increasing function of the ceiling k in + override_set_b's boolean output, for a fixed receiving register and + frontier. A tombstone only ever adds to C (its S and B are both 0, + so max() with any receiving register leaves that register's own S + and B untouched) — raising C can only weaken S's relative standing, + never strengthen it. This is what makes resurrection structurally + impossible and suppression the only possible direction, independent + of any bounded cube. + """ + violations = [] + vals = (0, 1, 2, 3) + baselines = (0, 10, 20) + ceilings = (0, 1, 2, 3, 4, 5) + for tie_policy in (CLEAR, SET): + for s in vals: + for c in vals: + for b in baselines: + for fv in baselines: + x_reg = RegB(s=s, c=c, b=b) + prev = None + for k in ceilings: + merged = merge_reg_b(x_reg, RegB(s=0, c=k, b=0)) + cur = override_set_b(merged, fv, tie_policy) + if prev is not None and cur and not prev: + violations.append(( + "I5d-non-monotonic-ceiling", tie_policy, + x_reg, fv, k, merged, + )) + prev = cur + return violations + + +def test_cross_device_compaction_suppression(device_cls=DeviceB): + """Compaction is NOT semantically transparent cross-device (I5c only + covers the same-device replay shape). A tombstone re-encodes + baseline-dominated death — frontier-relative, doesn't transfer + across devices — as a clear-counter ceiling — globally comparable — + so it can one-shot suppress an unrelated device's concurrent fresh + set whose own counters don't exceed that ceiling. + + Witness (Paul's report, illustrative — the cube below tests nearby + parameter values `f_x` in `(5, 15, 25, 35)`, not the literal + `f_x=30` used in the original report; the shape is the same): + Y: mark_unread -> RegB(1,0,0); frontier->10 (dead) -> compact -> + tombstone RegB(0,1,0) + X: offline, fresh mark_unread at frontier 30 -> RegB(1,0,30), LIVE + X merges Y's tombstone -> RegB(1,1,30) -> suppressed (clear-wins) + Control (Y publishes the uncompacted RegB(1,0,0) instead): X stays + RegB(1,0,30), LIVE — the divergence is caused by compaction, not + by the merge itself. + + Proves over a bounded cube, both tie policies: every divergence + between "X merges Y's tombstone" and "X merges Y's uncompacted + ancestor" is a suppression (never a resurrection — that would + contradict test_tombstone_merge_monotonic), and every suppression + recovers with one more local mark-unread, stable under tombstone + replay. + + Returns (cube_size, suppress_count, violations). + """ + violations = [] + cube_size = 0 + suppress_count = 0 + dead_vals = (0, 1, 2, 3) + dead_baselines = (0, 10) + dead_post_frontiers = (10, 20) + fresh_frontiers = (5, 15, 25, 35) + + for tie_policy in (CLEAR, SET): + for s_y in dead_vals: + for c_y in dead_vals: + for b_y in dead_baselines: + for f_y in dead_post_frontiers: + if f_y <= b_y: + continue + ancestor = RegB(s=s_y, c=c_y, b=b_y) + tomb = compact_b(ancestor, f_y, tie_policy) + if tomb is None or tomb == ancestor: + continue # virgin, or was live (not compacted) + + for f_x in fresh_frontiers: + cube_size += 1 + x_reg = RegB(s=1, c=0, b=f_x) + x_before = override_set_b(x_reg, f_x, tie_policy) + if not x_before: + violations.append(( + "I5d-setup-not-live", tie_policy, x_reg, f_x, + )) + continue + + ov_tomb = override_set_b( + merge_reg_b(x_reg, tomb), f_x, tie_policy + ) + ov_ancestor = override_set_b( + merge_reg_b(x_reg, ancestor), f_x, tie_policy + ) + + if ov_tomb == ov_ancestor: + continue + if ov_tomb and not ov_ancestor: + violations.append(( + "I5d-resurrection-vs-ancestor", tie_policy, + ancestor, tomb, x_reg, f_x, + )) + continue + + suppress_count += 1 + dev = device_cls("x") + dev.frontier["c0"] = f_x + dev.overrides["c0"] = merge_reg_b(x_reg, tomb) + dev.do_mark_unread("c0") + if not dev.override_is_set("c0", tie_policy): + violations.append(( + "I5d-recovery-failed", tie_policy, + ancestor, tomb, x_reg, f_x, dev.overrides["c0"], + )) + continue + dev.receive_merge({"contexts": { + "ov_s:c0": tomb.s, "ov_c:c0": tomb.c, "ov_b:c0": tomb.b, + }}) + if not dev.override_is_set("c0", tie_policy): + violations.append(( + "I5d-recovery-not-replay-stable", tie_policy, + ancestor, tomb, x_reg, f_x, dev.overrides["c0"], + )) + + return cube_size, suppress_count, violations + + +# --------------------------------------------------------------------------- +# New invariant: published-state merge closure (Paul's fix-scope item 2, +# generalizing Thufir's pass-1/2 CRITICAL — dead+dead merge resurrection) +# --------------------------------------------------------------------------- + +def _dead_register_points(tie_policy): + """Bounded cube of (label, reg, frontier) points independently + verified DEAD (inactive) under `tie_policy` by the real + `override_set_b` predicate — the death cause (baseline dominance, + clear-count dominance, or clear-wins tie) is whatever the predicate + actually computes for that point, not asserted by construction. + """ + vals = (0, 1, 2, 3) + baselines = (0, 10, 50) + frontiers = (0, 20, 60, 100) + points = [] + for s in vals: + for c in vals: + if s == 0 and c == 0: + continue # virgin: not a "dead override" case + for b in baselines: + for fv in frontiers: + reg = RegB(s=s, c=c, b=b) + if override_set_b(reg, fv, tie_policy): + continue # live: out of scope for this invariant + points.append((f"s={s}c={c}b={b}fv={fv}", reg, fv)) + return points + + +def test_published_merge_closure(device_cls=DeviceB): + """Over reachable *published* states: joining any two individually- + inactive published states must remain inactive. + + This targets Thufir's pass-1/2 CRITICAL directly: a dead register's + death cause is frontier-relative (baseline dominance) or + device-local-history-relative (clear-count dominance), but the + componentwise-max join recombines each register's `S`/`C`/`B` + independent of the history that produced them, so two individually- + dead registers could — before canonical publication — recombine + into a live join. Canonicalizing every override to `RegB(0, + max(S,C), 0)` before serialization (this round's CRITICAL fix) + folds every dead cause into a single globally-comparable `C` + ceiling with `S=0`, which per `test_tombstone_merge_monotonic` can + only ever raise a receiver's `C` — never resurrect. + + Checked two ways: + - Directed case: Thufir's exact witness pair — `RegB(3,2,0)` + inactive via baseline dominance at frontier 50, `RegB(1,2,100)` + inactive via clear dominance at frontier 100 — whose raw + componentwise join is `RegB(3,2,100)`, live (`S=3>C=2`, + `frontier(100) not> B(100)`). Both tie policies. + - General search: every pairwise join of a bounded cube of + independently-dead `(reg, frontier)` points (see + `_dead_register_points`), delivered to a fresh receiver in both + direct orders and via a one-hop relay that itself republishes + (re-canonicalizes) what it received before forwarding — covering + delayed/multi-hop delivery, not just direct pairwise merge. + + Returns (cube_size, violations). + """ + violations = [] + cube_size = 0 + + def _check_pair(tie_policy, label_a, blob_a, label_b, blob_b, tag): + nonlocal cube_size + cube_size += 1 + for first, second in [(blob_a, blob_b), (blob_b, blob_a)]: + recv = device_cls("recv") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + tag, tie_policy, label_a, label_b, recv.overrides.get("c0"), + )) + # Multi-hop: a relay receives blob_a alone, republishes + # (re-canonicalizes) before forwarding, then the receiver gets + # the relayed form plus blob_b directly, in both orders. + relay = device_cls("relay") + relay.receive_merge(blob_a) + relayed = relay.publish_blob(tie_policy) + for first, second in [(relayed, blob_b), (blob_b, relayed)]: + recv = device_cls("recv_hop") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + tag + "-multihop", tie_policy, label_a, label_b, + recv.overrides.get("c0"), + )) + + # --- Directed case: Thufir's exact witness pair. --- + for tie_policy in (CLEAR, SET): + reg_a, front_a = RegB(s=3, c=2, b=0), 50 + reg_b, front_b = RegB(s=1, c=2, b=100), 100 + assert not override_set_b(reg_a, front_a, tie_policy) + assert not override_set_b(reg_b, front_b, tie_policy) + + dev_a = device_cls("a") + dev_a.frontier["c0"] = front_a + dev_a.overrides["c0"] = reg_a + dev_b = device_cls("b") + dev_b.frontier["c0"] = front_b + dev_b.overrides["c0"] = reg_b + + _check_pair( + tie_policy, f"thufir-witness-A={reg_a}@{front_a}", + dev_a.publish_blob(tie_policy), + f"thufir-witness-B={reg_b}@{front_b}", + dev_b.publish_blob(tie_policy), + "merge-closure-thufir-witness", + ) + + # --- General search over a bounded cube of dead published states. --- + for tie_policy in (CLEAR, SET): + points = _dead_register_points(tie_policy) + for label_a, reg_a, front_a in points: + dev_a = device_cls("a") + dev_a.frontier["c0"] = front_a + dev_a.overrides["c0"] = reg_a + blob_a = dev_a.publish_blob(tie_policy) + for label_b, reg_b, front_b in points: + dev_b = device_cls("b") + dev_b.frontier["c0"] = front_b + dev_b.overrides["c0"] = reg_b + blob_b = dev_b.publish_blob(tie_policy) + _check_pair( + tie_policy, label_a, blob_a, label_b, blob_b, + "merge-closure-cube", + ) + + return cube_size, violations + + +# --------------------------------------------------------------------------- +# I6: replay harmless +# --------------------------------------------------------------------------- + +def test_replay_harmless(device_cls=DeviceB): + violations = [] + for tie_policy in [CLEAR, SET]: + for ctx in CONTEXTS: + d = device_cls("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob = d.publish_blob(tie_policy) + state_before = ( + dict(d.frontier), + {k: v for k, v in d.overrides.items()}, + ) + d.receive_merge(blob) + d.receive_merge(blob) + d.receive_merge(blob) + state_after = ( + dict(d.frontier), + {k: v for k, v in d.overrides.items()}, + ) + if state_before != state_after: + violations.append(("I6-replay", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# I7: legacy rewrite +# --------------------------------------------------------------------------- + +def test_legacy_rewrite_b(): + """B's sibling keys survive legacy sanitization (round-trip).""" + violations = [] + for ctx in CONTEXTS: + d = DeviceB("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob = d.publish_blob() + sanitized = legacy_sanitize_blob(blob) + + recv_orig = DeviceB("recv1") + recv_orig.receive_merge(blob) + recv_san = DeviceB("recv2") + recv_san.receive_merge(sanitized) + + for c in CONTEXTS: + if recv_orig.overrides.get(c) != recv_san.overrides.get(c): + violations.append(("I7-B-sanitize-mutated", c, + recv_orig.overrides.get(c), + recv_san.overrides.get(c))) + return violations + + +def test_legacy_erasure_a(): + """A's top-level overrides field is erased by legacy rewrite. Expected witness.""" + d = DeviceA("d0") + d.frontier["c0"] = 10 + d.do_mark_unread("c0") + blob = d.publish_blob() + assert "overrides" in blob + legacy_blob = {"v": 1, "client_id": "legacy", "contexts": dict(blob["contexts"])} + return "overrides" not in legacy_blob + + +# --------------------------------------------------------------------------- +# I8: bounded key growth +# --------------------------------------------------------------------------- + +def test_bounded_growth(): + """I8: bounded key growth, canonical wire shape. A live override + (last action = mark_unread, still within baseline) publishes + exactly 3 keys/ctx; a dead override (mark_read past baseline, or + C > S under clear-wins) canonicalizes to exactly 1 key/ctx + (`ov_c:` tombstone) at publish time — never 0 (virgin-only) or 3 + (dead-but-uncompacted, which the pre-fix serializer allowed). + """ + violations = [] + for ctx in CONTEXTS: + # Live: 100 set/clear round-trips, ending on a fresh mark_unread + # so S > C (live under both tie policies) at publish time. + d = DeviceB("d0") + d.frontier[ctx] = 10 + for _ in range(100): + d.do_mark_unread(ctx) + d.do_mark_read(ctx, d.effective_frontier(ctx) + 1) + d.do_mark_unread(ctx) + blob = d.publish_blob(CLEAR) + ov_keys = [k for k in blob["contexts"] if k.startswith("ov_")] + if ov_keys != [f"ov_s:{ctx}", f"ov_c:{ctx}", f"ov_b:{ctx}"]: + violations.append(("I8-growth-live", ctx, ov_keys)) + + # Dead: advance the frontier past baseline B — override_set_b's + # baseline-dominance clause forces S dead regardless of S vs C. + d.do_advance_frontier(ctx, d.effective_frontier(ctx) + 100) + tomb_blob = d.publish_blob(CLEAR) + tomb_keys = [k for k in tomb_blob["contexts"] if k.startswith("ov_")] + if tomb_keys != [f"ov_c:{ctx}"]: + violations.append(("I8-growth-tombstone", ctx, tomb_keys)) + return violations + + +def test_wire_shape_exact(): + """Exact wire-shape regression (Paul's fix-scope item 4): a live + override serializes to exactly 3 `ov_*` keys, a dead override to + exactly 1 (`ov_c:` only, zero-valued `ov_s`/`ov_b` omitted), and a + virgin override to exactly 0. Checked directly against + `publish_blob`'s output, independent of `do_compact`. + """ + violations = [] + for tie_policy in (CLEAR, SET): + # Live. + d_live = DeviceB("d0") + d_live.frontier["c0"] = 10 + d_live.do_mark_unread("c0") + live_blob = d_live.publish_blob(tie_policy) + live_keys = sorted(k for k in live_blob["contexts"] if k.startswith("ov_")) + if live_keys != ["ov_b:c0", "ov_c:c0", "ov_s:c0"]: + violations.append(("wire-shape-live", tie_policy, live_keys)) + + # Dead (clear-wins only: S==C>0 is dead under CLEAR, live under + # SET — use baseline dominance instead so it's dead under both). + d_dead = DeviceB("d0") + d_dead.frontier["c0"] = 10 + d_dead.do_mark_unread("c0") + d_dead.do_advance_frontier("c0", 100) + dead_blob = d_dead.publish_blob(tie_policy) + dead_keys = sorted(k for k in dead_blob["contexts"] if k.startswith("ov_")) + if dead_keys != ["ov_c:c0"]: + violations.append(("wire-shape-tombstone", tie_policy, dead_keys)) + if dead_blob["contexts"]["ov_c:c0"] != 1: + violations.append(( + "wire-shape-tombstone-ceiling", tie_policy, + dead_blob["contexts"]["ov_c:c0"], + )) + + # Virgin: no override ever set for this context. + d_virgin = DeviceB("d0") + d_virgin.frontier["c0"] = 10 + d_virgin.overrides["c0"] = RegB(s=0, c=0, b=0) + virgin_blob = d_virgin.publish_blob(tie_policy) + virgin_keys = [k for k in virgin_blob["contexts"] if k.startswith("ov_")] + if virgin_keys: + violations.append(("wire-shape-virgin", tie_policy, virgin_keys)) + + return violations + + +# --------------------------------------------------------------------------- +# I9: DeviceA counter absorption +# --------------------------------------------------------------------------- + +def test_a_counter_absorption(): + """After receiving a blob with counter=10, a local action must use counter>10.""" + d0 = DeviceA("d0") + d0.frontier["c0"] = 10 + d0.counter = 10 + d0.do_mark_unread("c0") + blob0 = d0.publish_blob() + + d1 = DeviceA("d1") + d1.frontier["c0"] = 10 + d1.receive_merge(blob0) + assert d1.counter >= 10, f"counter not absorbed: {d1.counter}" + + d1.do_mark_read("c0", 20) + reg = d1.overrides.get("c0") + assert reg is not None and reg.counter > 10, \ + f"post-receive clear at counter {reg.counter} would lose to set at 10" + return True + + +# --------------------------------------------------------------------------- +# Identity-free (B): reinstall convergence +# --------------------------------------------------------------------------- + +def test_b_identity_free(device_cls=DeviceB): + violations = [] + for tie_policy in [CLEAR, SET]: + for ctx in CONTEXTS: + d = device_cls("d0") + d.frontier[ctx] = 10 + d.do_mark_unread(ctx) + blob1 = d.publish_blob(tie_policy) + + d_re = device_cls("d0_reinstalled") + d_re.receive_merge(blob1) + d_re.do_mark_read(ctx, 20) + blob2 = d_re.publish_blob(tie_policy) + + verdicts = set() + for first, second in [(blob1, blob2), (blob2, blob1)]: + recv = device_cls("recv") + recv.receive_merge(first) + recv.receive_merge(second) + verdicts.add(recv.override_is_set(ctx, tie_policy)) + if len(verdicts) > 1: + violations.append(("identity-free", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# Legacy prune/trim interaction +# --------------------------------------------------------------------------- + +def test_legacy_prune_interaction(): + """ov_ keys survive prune; msg:ov_ nested keys would be pruned (state loss).""" + base = {"c0": 50, "msg:m1": 30, "thread:t1": 40} + ov = {"ov_s:c0": 1, "ov_c:c0": 0, "ov_b:c0": 10} + all_keys = {**base, **ov} + pruned = legacy_prune(all_keys, horizon=35) + ov_survived = all(k in pruned for k in ov) + msg_pruned = "msg:m1" not in pruned + + nested = {"msg:ov_s:c0": 1, "msg:ov_c:c0": 0, "msg:ov_b:c0": 10} + pruned_nested = legacy_prune({**base, **nested}, horizon=35) + nested_lost = any(k not in pruned_nested for k in nested) + return ov_survived, msg_pruned, nested_lost + + +def test_legacy_trim_interaction(): + """Excess override keys block legacy publish when budget exceeded.""" + contexts = {"c0": 50} + for i in range(1000): + contexts[f"ov_s:c{i}"] = 1 + contexts[f"ov_c:c{i}"] = 0 + contexts[f"ov_b:c{i}"] = 10 + _, fits = legacy_trim(contexts, "client1", max_bytes=32768) + return not fits + + +# --------------------------------------------------------------------------- +# Multi-slot union +# --------------------------------------------------------------------------- + +def test_multi_slot_union(device_cls=DeviceB): + """Split a published blob across 2 slots using the atomic-grouping rule, + deliver each slot separately, verify convergence with delivering the full blob. + + Production: mergeReadStateEvents merges per-slot blobs with per-context + max(). Override sibling keys are individual context entries, so they + follow the same merge path. The atomic-grouping rule requires that all + `ov_*` sibling keys for a context travel with that context's frontier + key in the same slot — `split_blob_into_slots` enforces this. + """ + violations = [] + for tie_policy in [CLEAR, SET]: + dev = device_cls("d0") + dev.frontier["c0"] = 10 + dev.frontier["c1"] = 20 + dev.do_mark_unread("c0") + dev.do_mark_read("c1", 30) + + full_blob = dev.publish_blob(tie_policy) + slots = dev.split_blob_into_slots(tie_policy, n_slots=2) + slot0, slot1 = slots[0], slots[1] + + recv_full = device_cls("recv_full") + recv_full.receive_merge(full_blob) + + for first, second in [(slot0, slot1), (slot1, slot0)]: + recv_split = device_cls("recv_split") + recv_split.receive_merge(first) + recv_split.receive_merge(second) + + for ctx in CONTEXTS: + ov_full = recv_full.override_is_set(ctx, tie_policy) + ov_split = recv_split.override_is_set(ctx, tie_policy) + f_full = recv_full.effective_frontier(ctx) + f_split = recv_split.effective_frontier(ctx) + if ov_full != ov_split: + violations.append(("multi-slot-override", tie_policy, ctx)) + if f_full != f_split: + violations.append(("multi-slot-frontier", tie_policy, ctx)) + return violations + + +# --------------------------------------------------------------------------- +# Interleaved-delivery grouping: Thufir's CRITICAL transport counterexample +# +# Without the atomic-grouping rule a compliant publisher would still split +# ov_s:/ov_c:/ov_b: across slots as independent entries. M8's per-entry +# split places frontier+ov_s: in slot 0 and ov_b:+ov_c: in slot 1. An +# observer holding only slot 0 reconstructs RegB(1,0,0), judges it +# baseline-dead (B=0 ≤ frontier=10), and canonically publishes tombstone +# RegB(0,1,0). After all slots and that transient tombstone are eventually +# merged the result is RegB(1,1,10) — dead under clear-wins — +# permanently suppressing a live override. +# +# The atomic-grouping rule closes this: every ov_* entry for a context +# travels with the context's frontier entry, so a receiver always sees +# either the complete register or nothing. This test exercises both: +# - The PASS path: grouped slots → no false tombstone possible. +# - The FAIL path (M8): per-entry split → Thufir's exact witness reproduced. +# --------------------------------------------------------------------------- + +def test_interleaved_delivery_grouping(device_cls=DeviceB): + """Exercise receive-one-slot → canonical re-publish → receive-rest → + re-publish permutations, both slot orders, including delayed delivery + of both transient re-publications to a third observer. + + Protocol sequence (exact, per Paul's brief): + 1. partial_obs receives first_slot → publishes transient_1. + 2. partial_obs receives second_slot → publishes transient_2. + 3. Third-party finals receive BOTH source slots AND both transient + publications in relevant interleaving orders. + Oracle: after eventual delivery of ALL blobs (both source slots + + both transients), every observer's override matches source liveness. + + With the atomic-grouping rule (default DeviceB): + - The compliant split puts the full register in one slot, the other + is empty. partial_obs after step 1 holds either the complete + register (live → transient_1 is live) or nothing (transient_1 is + empty/virgin). Either way, step 2 delivers the remaining (possibly + empty) slot. Final merge of all blobs = source liveness. PASS. + With per-entry splitting (M8): + - slot 0 carries frontier + ov_s: (partial → RegB(1,0,0), dead). + transient_1 is tombstone RegB(0,1,0). After step 2 partial_obs + holds full register but transient_1 tombstone is already in + circulation. Finals that receive transient_1 get + RegB(1,1,10) — dead under clear-wins. FAIL (Thufir's witness). + """ + violations = [] + + # Source: live override RegB(1,0,10) at frontier=10 — Thufir's witness. + src_s, src_c, src_b, src_front = 1, 0, 10, 10 + + for tie_policy in (CLEAR, SET): + src = device_cls("src") + src.frontier["c0"] = src_front + src.overrides["c0"] = RegB(s=src_s, c=src_c, b=src_b) + + # Confirm source is actually live. + assert src.override_is_set("c0", tie_policy), ( + f"test precondition: source must be live under {tie_policy}" + ) + + # Produce the source's two slots via the (possibly mutated) split. + slots = src.split_blob_into_slots(tie_policy, n_slots=2) + slot0, slot1 = slots[0], slots[1] + src_live = src.override_is_set("c0", tie_policy) + + for first_slot, second_slot in [(slot0, slot1), (slot1, slot0)]: + # Step 1: partial_obs receives first slot, canonically re-publishes. + partial_obs = device_cls("partial_obs") + partial_obs.receive_merge(first_slot) + transient_1 = partial_obs.publish_blob(tie_policy) + + # Step 2: partial_obs receives second slot, publishes again. + partial_obs.receive_merge(second_slot) + transient_2 = partial_obs.publish_blob(tie_policy) + + # Step 3: third-party finals receive BOTH source slots AND both + # transient publications, in several representative interleaving + # orders. All must agree with source liveness. + # Representative orders: transient_1 before both slots (most + # dangerous under M8), transient_1 after both slots, and + # interleaved. We check 3 explicit orders rather than all 4! + # permutations (24) for speed; M8's canonical false-clear path + # (transient_1 first, then second_slot only) is order 1. + check_orders = [ + # Most dangerous: transient_1 arrives first, before any source + [transient_1, slot0, slot1, transient_2], + # Normal: both source slots first, then both transients + [slot0, slot1, transient_1, transient_2], + # Interleaved: first source, transient_1, second source, transient_2 + [first_slot, transient_1, second_slot, transient_2], + ] + + for order in check_orders: + final = device_cls("final") + for blob in order: + final.receive_merge(blob) + final_live = final.override_is_set("c0", tie_policy) + if final_live != src_live: + t1_reg = partial_obs.overrides.get("c0") + violations.append(( + "interleaved-delivery-false-clear", + tie_policy, + f"slot_order=(first={list(first_slot['contexts'].keys())[:2]}...)", + f"delivery_order={[list(b['contexts'].keys())[:2] for b in order]}", + f"transient_1_reg={t1_reg}", + f"final_reg={final.overrides.get('c0')}", + f"expected_live={src_live} got_live={final_live}", + )) + + return violations + + +# --------------------------------------------------------------------------- +# Escaped-context slot-grouping regression +# +# Thufir's CRITICAL (round 2): a raw context ID that starts with a +# reserved prefix (e.g. "ov_s:evil") escapes to "esc:ov_s:evil" as its +# frontier wire key. Before the fix, split_blob_into_slots grouped the +# frontier by its wire key ("esc:ov_s:evil") but the ov_* siblings by +# the raw suffix ("ov_s:evil") — two identities for one logical context. +# The frontier and its siblings landed in different slots. +# +# Across publication cycles the replaceable slot d-tag coordinates +# update slot-by-slot. A relay can therefore serve: new frontier slot +# (just published, carries esc:ov_s:evil=10) + stale override slot +# (old coordinate, carries ov_s/ov_c/ov_b at b=0 from the old pub). +# The reconstructed register is RegB(s=1, c=0, b=0) at frontier=10 — +# baseline-dead. Canonical re-publication emits tombstone RegB(0,1,0). +# Eventually both current slots + the transient merge to RegB(1,1,10) — +# dead under clear-wins — permanently suppressing a live override. +# +# The fix: derive the frontier's group identity via unescape_context_key +# so it joins the same group as its ov_* siblings. This test exercises +# both directions: M9 (reverts to escaped-key grouping) must reproduce +# the witness, and the correct model must pass. +# --------------------------------------------------------------------------- + +def test_escaped_context_slot_grouping(device_cls=DeviceB): + """Regression for escaped-context identity mismatch in split_blob_into_slots. + + Scenario: + 1. Source has a live override on raw context "ov_s:evil" (escapes to + "esc:ov_s:evil" as frontier wire key) — Thufir's exact escaped witness. + 2. Source publishes twice: first at frontier=0/b=0, then after advancing + frontier to 10 and re-marking unread (b=10). Each publication produces + 2 slots. Simulates a relay retaining a stale old-cycle slot under its + old replaceable coordinate while only the new-cycle slot for the OTHER + half has been updated — the old/new slot-coordinate mixture. + 3. An observer receives: new-cycle frontier-bearing slot (frontier=10, + esc:ov_s:evil=10) + stale old-cycle override slot (ov_s/ov_c/ov_b from + first pub where b=0). + 4. Observer canonically re-publishes (mandatory, per NIP-RS spec). + 5. A third-party final observer receives both current-cycle source slots + plus the transient re-publication. + 6. Oracle: final observer must see the override as live. + + With the unescape-before-group fix: frontier + ov_* siblings always land + in the same slot → no partial register → no false tombstone. PASS. + With M9 (escaped-key grouping): frontier in one slot, siblings in another + → partial reconstruction → false tombstone → final merge dead. FAIL. + """ + violations = [] + raw_ctx = "ov_s:evil" + + for tie_policy in (CLEAR, SET): + # --- Publication cycle 1: initial state, frontier=0 --- + src_old = device_cls("src") + src_old.frontier[raw_ctx] = 0 + src_old.do_mark_unread(raw_ctx) # RegB(s=1, c=0, b=0) + old_slots = src_old.split_blob_into_slots(tie_policy, n_slots=2) + # old_slots[0] is the "stale old-coordinate slot" a relay may retain. + + # --- Publication cycle 2: frontier advances, re-mark-unread --- + src_new = device_cls("src") + src_new.frontier[raw_ctx] = 10 + src_new.do_mark_unread(raw_ctx) # RegB(s=1, c=0, b=10) — live at frontier=10 + + assert src_new.override_is_set(raw_ctx, tie_policy), ( + f"test precondition: source must be live under {tie_policy}" + ) + + new_slots = src_new.split_blob_into_slots(tie_policy, n_slots=2) + + # --- Full delivery: both new slots → both current-cycle slot arrive --- + recv_full = device_cls("recv_full") + recv_full.receive_merge(new_slots[0]) + recv_full.receive_merge(new_slots[1]) + if not recv_full.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-full-delivery-dead", tie_policy, + f"full={recv_full.overrides.get(raw_ctx)}", + )) + + # --- Mixture: new frontier-bearing slot + stale old override slot --- + # Identify which new slot carries the frontier and which carries ov_*, + # then pair the frontier slot with the old-cycle override slot. + wire_frontier = escape_context_key(raw_ctx) + + new_frontier_slot_idx = 0 if wire_frontier in new_slots[0]["contexts"] else 1 + new_frontier_slot = new_slots[new_frontier_slot_idx] + old_override_slot = old_slots[1 - new_frontier_slot_idx] # opposite slot + + # Check whether the frontier and ov_* siblings are co-located in new_slots. + ov_s_key = f"ov_s:{raw_ctx}" + frontier_and_ov_same_slot = ( + wire_frontier in new_slots[new_frontier_slot_idx]["contexts"] and + ov_s_key in new_slots[new_frontier_slot_idx]["contexts"] + ) + + if frontier_and_ov_same_slot: + # Correct grouping: old override slot has nothing relevant, mixture + # is safe by construction — the stale slot is just an empty dict. + # Verify anyway for defense-in-depth. + obs = device_cls("obs") + obs.receive_merge(new_frontier_slot) + obs.receive_merge(old_override_slot) + transient = obs.publish_blob(tie_policy) + + final = device_cls("final") + final.receive_merge(new_slots[0]) + final.receive_merge(new_slots[1]) + final.receive_merge(transient) + if not final.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-grouped-mixture-dead", tie_policy, + f"transient={obs.overrides.get(raw_ctx)}", + f"final={final.overrides.get(raw_ctx)}", + )) + else: + # Mismatched grouping (M9 path): frontier and siblings split. + # The mixture produces a partial register → false tombstone. + obs = device_cls("obs") + obs.receive_merge(new_frontier_slot) # gets frontier=10, no ov_* + obs.receive_merge(old_override_slot) # gets ov_s/ov_c/ov_b at b=0 + transient = obs.publish_blob(tie_policy) + + # Final observer gets everything: both new slots + transient. + for order in [(new_slots[0], new_slots[1]), (new_slots[1], new_slots[0])]: + final = device_cls("final") + final.receive_merge(order[0]) + final.receive_merge(order[1]) + final.receive_merge(transient) + if not final.override_is_set(raw_ctx, tie_policy): + violations.append(( + "escaped-ctx-mixture-false-clear", tie_policy, + f"obs_reg={obs.overrides.get(raw_ctx)}", + f"transient_reg={transient['contexts']}", + f"final_reg={final.overrides.get(raw_ctx)}", + )) + + return violations + + +# --------------------------------------------------------------------------- +# Reserved key namespace: adversarial prefix collision +# --------------------------------------------------------------------------- + +def test_reserved_namespace_collision(): + """A genuine user context whose raw ID begins with the reserved `ov_` + stem (e.g. a pre-existing legacy context literally named `ov_s:evil`) + must round-trip as frontier state, not be misparsed as a control key + for a different context, and must not collide with a real override's + sibling keys in the same flattened contexts map. + + Exercises: escape on publish, unescape on receive, and a live + override on a DIFFERENT context in the same blob to prove no + control-key collision occurs. + """ + violations = [] + adversarial_raw = "ov_s:evil" # would misparse as ov_s: control for ctx "evil" + real_ctx = "c0" + + # Escaping must be a no-op for every context ID Buzz actually + # generates, and must trigger for the adversarial one. + for benign in ("b68cd7cb-6f8d-4641-b743-a7349eb4114b", + "msg:" + "a" * 64, "thread:" + "b" * 64): + if escape_context_key(benign) != benign: + violations.append(("namespace-benign-escaped", benign)) + if escape_context_key(adversarial_raw) == adversarial_raw: + violations.append(("namespace-adversarial-not-escaped", adversarial_raw)) + if not escape_context_key(adversarial_raw).startswith(ESCAPE_PREFIX): + violations.append(("namespace-adversarial-missing-marker", adversarial_raw)) + + dev = DeviceB("d0") + dev.frontier[adversarial_raw] = 42 + dev.frontier[real_ctx] = 5 + dev.do_mark_unread(real_ctx) + blob = dev.publish_blob() + + wire_key = escape_context_key(adversarial_raw) + if wire_key not in blob["contexts"]: + violations.append(("namespace-wire-key-missing", wire_key, blob["contexts"])) + if blob["contexts"].get(wire_key) != 42: + violations.append(("namespace-value-corrupted", wire_key, blob["contexts"].get(wire_key))) + + recv = DeviceB("recv") + recv.receive_merge(blob) + if recv.effective_frontier(adversarial_raw) != 42: + violations.append(( + "namespace-roundtrip-failed", adversarial_raw, + recv.effective_frontier(adversarial_raw), + )) + if adversarial_raw in recv.overrides: + violations.append(("namespace-misparsed-as-override", adversarial_raw)) + if recv.overrides.get(real_ctx) is None or recv.overrides[real_ctx].s == 0: + violations.append(("namespace-real-override-corrupted", real_ctx, recv.overrides.get(real_ctx))) + + return violations + + +# --------------------------------------------------------------------------- +# Run all +# --------------------------------------------------------------------------- + +def run_all(): + print("=" * 60) + print("NIP-RS manual-unread exhaustive model") + print("=" * 60) + total_violations = 0 + + def report(name, violations): + nonlocal total_violations + n = len(violations) if isinstance(violations, list) else 0 + total_violations += n + status = "PASS" if n == 0 else f"FAIL ({n})" + print(f" {name}: {status}") + if n > 0: + for v in violations[:3]: + print(f" {v}") + + print("\n--- I1: merge algebra (B) ---") + report("assoc/commut/idempot", test_merge_algebra_b()) + + print("\n--- I1: merge algebra (A) ---") + report("assoc/commut/idempot", test_merge_algebra_a()) + + print("\n--- I2+I3+I5: BFS explorer (B, clear-wins) ---") + n, v = explore_b(max_depth=4, tie_policy=CLEAR) + print(f" states explored: {n}") + report("convergence+frontier+compaction", v) + + print("\n--- I2+I3+I5: BFS explorer (B, set-wins) ---") + n, v = explore_b(max_depth=4, tie_policy=SET) + print(f" states explored: {n}") + report("convergence+frontier+compaction", v) + + print("\n--- I4: concurrent set/clear stability ---") + report("stable winner", test_concurrent_stability()) + + print("\n--- I5: compaction register-level exhaustive ---") + report("all register values x policies", test_compaction_register_exhaustive()) + + print("\n--- I5c: directed deep-history (compact -> reuse -> stale delivery) ---") + cube_size, deep_v = test_deep_history_compaction() + print(f" parameter cube size: {cube_size}") + report("no divergence from never-compact reference", deep_v) + + print("\n--- I5c: tombstone + stale-ancestor merge (direct) ---") + report("no resurrection", test_tombstone_stale_merge_direct()) + + print("\n--- I5d: tombstone-merge monotonicity (structural lemma) ---") + report("ceiling never strengthens S", test_tombstone_merge_monotonic()) + + print("\n--- I5d: cross-device compaction transparency (suppress-only) ---") + cd_cube, cd_suppress, cd_v = test_cross_device_compaction_suppression() + print(f" parameter cube size: {cd_cube} suppressions observed: {cd_suppress}") + report("suppress-only + recoverable", cd_v) + + print("\n--- Published-state merge closure (canonical publication guarantee) ---") + mc_cube, mc_v = test_published_merge_closure() + print(f" pairs checked: {mc_cube}") + report("no dead+dead resurrection", mc_v) + + print("\n--- I6: replay harmless ---") + report("replay", test_replay_harmless()) + + print("\n--- I7: legacy rewrite (B) ---") + report("sibling keys survive", test_legacy_rewrite_b()) + + print("\n--- I7: legacy erasure (A) — expected witness ---") + erased = test_legacy_erasure_a() + print(f" overrides erased by legacy: {'CONFIRMED' if erased else 'NOT FOUND'}") + if not erased: + total_violations += 1 + + print("\n--- I8: bounded growth ---") + report("canonical wire shape (3 live / 1 tombstone)", test_bounded_growth()) + + print("\n--- I8: exact wire-shape regression ---") + report("live=3 keys, tombstone=1 key, virgin=0 keys", test_wire_shape_exact()) + + print("\n--- I9: DeviceA counter absorption ---") + absorbed = test_a_counter_absorption() + print(f" post-receive counter > received: {'CONFIRMED' if absorbed else 'FAIL'}") + if not absorbed: + total_violations += 1 + + print("\n--- Identity-free (B) ---") + report("reinstall convergence", test_b_identity_free()) + + print("\n--- Legacy prune interaction ---") + ov_ok, msg_ok, nested_lost = test_legacy_prune_interaction() + print(f" ov_ keys survive: {'PASS' if ov_ok else 'FAIL'}") + print(f" msg: pruned at horizon: {'PASS' if msg_ok else 'FAIL'}") + print(f" nested msg:ov_ lost: {'CONFIRMED (hazard)' if nested_lost else 'NOT FOUND'}") + if not ov_ok: + total_violations += 1 + + print("\n--- Legacy trim interaction ---") + blocked = test_legacy_trim_interaction() + print(f" excess overrides block publish: {'CONFIRMED (hazard)' if blocked else 'NOT FOUND'}") + + print("\n--- Multi-slot union ---") + report("split+merge convergence", test_multi_slot_union()) + + print("\n--- Interleaved delivery + atomic grouping rule ---") + report("no false clear under slot interleaving", test_interleaved_delivery_grouping()) + + print("\n--- Escaped-context slot-grouping regression ---") + report("escaped ctx: frontier + ov_* siblings same slot", test_escaped_context_slot_grouping()) + + print("\n--- Reserved key namespace: adversarial prefix collision ---") + report("escape/unescape + no misparse", test_reserved_namespace_collision()) + + print("\n" + "=" * 60) + if total_violations == 0: + print("ALL INVARIANTS HOLD — 0 violations") + else: + print(f"VIOLATIONS: {total_violations}") + print("=" * 60) + return total_violations + + +if __name__ == "__main__": + import sys + sys.exit(0 if run_all() == 0 else 1) diff --git a/docs/formal/nip-rs-unread/model.py b/docs/formal/nip-rs-unread/model.py new file mode 100644 index 0000000000..4400280571 --- /dev/null +++ b/docs/formal/nip-rs-unread/model.py @@ -0,0 +1,492 @@ +"""Bounded exhaustive model comparing two NIP-RS manual-unread encodings. + +Candidate A: lexicographic operation register + Per context: {counter, client_tiebreak, op in {SET,CLEAR}, baseline} + in a NEW top-level field beside `contexts`. + Merge = max tuple (counter, tiebreak, op-rule on full tie). + +Candidate B: two grow-only counters + baseline + Per context: S (set counter), C (clear counter), B (frontier-at-set-time) + as sibling keys under `contexts` (ov_s:, ov_c:, ov_b: prefixes). + Action: own counter := max(S,C)+1; set also writes B := effective frontier. + Merge = componentwise max. Tie policy on S == C is a parameter. + +Both share: + - Frontier: grow-only max() per NIP-RS v1 (unchanged). + - Verdict: unread(ctx) = latest > effective_frontier(ctx) OR override_set(ctx). + - Mark-read = advance frontier + clear override. + - Mark-unread = set override with baseline B = current effective frontier. + - Natural frontier advance strictly past B dominates a stale set. + +Device simulators use overridable methods (_override_set, _compact, _merge_reg, +_bump, _sanitize_value) so the mutation harness can inject weakened rules via +subclassing without monkeypatching. +""" +from dataclasses import dataclass +from typing import Optional + + +SET = "SET" +CLEAR = "CLEAR" + + +# --------------------------------------------------------------------------- +# Reserved key namespace + escaping +# +# NIP-RS v1 context IDs are arbitrary UTF-8 (spec :89, :113-114), so a +# pre-existing opaque context could legitimately begin with `ov_s:`, +# `ov_c:`, or `ov_b:` and collide with a control key for a DIFFERENT +# context in the same flattened `contexts` map. `ov_` (the shared +# 3-byte stem) and the escape marker itself are reserved; any raw +# context ID that would collide is escaped before being used as a +# plain frontier key. Escaping is a no-op for every context ID Buzz +# actually generates (channel UUID, `msg:`, `thread:` +# — none start with `ov_` or `esc:`), so the common case pays zero +# bytes. Only a pathological ID pays the 4-byte `esc:` cost. +# +# This protects context IDs generated by amendment-aware clients. +# It does NOT retroactively protect a context that a PRE-EXISTING +# legacy client already published unescaped before the amendment +# shipped — that residual hazard is documented, not solved (see +# NOTE.md "Reserved key namespace"). +# --------------------------------------------------------------------------- + +ESCAPE_PREFIX = "esc:" +_RESERVED_STEM = "ov_" + + +def _needs_escape(raw_key: str) -> bool: + return raw_key.startswith(_RESERVED_STEM) or raw_key.startswith(ESCAPE_PREFIX) + + +def escape_context_key(raw_key: str) -> str: + return ESCAPE_PREFIX + raw_key if _needs_escape(raw_key) else raw_key + + +def unescape_context_key(wire_key: str) -> str: + if wire_key.startswith(ESCAPE_PREFIX): + return wire_key[len(ESCAPE_PREFIX):] + return wire_key + + +# --------------------------------------------------------------------------- +# Candidate B — two grow-only counters + baseline +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class RegB: + s: int = 0 + c: int = 0 + b: int = 0 + + +def merge_reg_b(a: Optional[RegB], b: Optional[RegB]) -> Optional[RegB]: + if a is None: + return b + if b is None: + return a + return RegB(s=max(a.s, b.s), c=max(a.c, b.c), b=max(a.b, b.b)) + + +def override_set_b(reg: Optional[RegB], frontier_val: int, tie_policy=CLEAR) -> bool: + if reg is None: + return False + if frontier_val > reg.b and reg.s > 0: + return False + if reg.s > reg.c: + return True + if reg.s == reg.c and reg.s > 0: + return tie_policy == SET + return False + + +def compact_b(reg: RegB, frontier_val: int, tie_policy=CLEAR) -> Optional[RegB]: + """Compact override state. + + Tombstone-floor design: a register with any recorded counter + activity (S>0 or C>0) is never fully deleted. Its counter + high-water-mark is exactly what prevents a stale replica — + any (S,C) pair below that ceiling — from dominating a freshly + created register after compaction (delete-on-dominance made + counters reusable: a dead register dropped entirely, then a new + local set/clear pair restarted from S=0/C=0, so a delayed stale + peer snapshot with S>0 could out-rank the new state on replay). + Only a virgin register (S==0, C==0, no activity ever recorded) + has no ceiling to protect and compacts to None. + + A live override (per `override_set_b`, which is already + policy-aware) is returned unchanged — compaction only touches dead + state. Dead overrides — whether dominated by C>S, tied under + clear-wins, or baseline-dominated by frontier advance — compact to + the clear-tombstone floor `RegB(s=0, c=max(S,C), b=0)`: S is + zeroed (no longer overriding), but C retains the ceiling so both a + future local bump (`max(S,C)+1`) and a componentwise-max merge with + any pre-compaction stale snapshot start strictly above the + historical maximum, never below it. + """ + if reg.s == 0 and reg.c == 0: + return None + if override_set_b(reg, frontier_val, tie_policy): + return reg + return RegB(s=0, c=max(reg.s, reg.c), b=0) + + +# --------------------------------------------------------------------------- +# Candidate A — lexicographic operation register +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class RegA: + counter: int = 0 + tiebreak: str = "" + op: str = CLEAR + baseline: int = 0 + + def as_tuple(self, op_wins): + op_val = 1 if self.op == op_wins else 0 + return (self.counter, self.tiebreak, op_val) + + +def merge_reg_a(a: Optional[RegA], b: Optional[RegA], tie_op=CLEAR) -> Optional[RegA]: + if a is None: + return b + if b is None: + return a + at = a.as_tuple(tie_op) + bt = b.as_tuple(tie_op) + if at == bt: + return RegA( + counter=a.counter, tiebreak=a.tiebreak, op=a.op, + baseline=max(a.baseline, b.baseline), + ) + return a if at > bt else b + + +# --------------------------------------------------------------------------- +# Device simulation — Candidate B +# --------------------------------------------------------------------------- + +class DeviceB: + """Simulates one device's NIP-RS read-state blob with manual-unread + override layer (candidate B encoding). + + All model operations go through overridable _methods so the mutation + harness can inject weakened rules via subclassing. + """ + + def __init__(self, client_id, is_legacy=False): + self.client_id = client_id + self.is_legacy = is_legacy + self.frontier = {} + self.overrides = {} + + def effective_frontier(self, ctx): + return self.frontier.get(ctx, 0) + + def _override_set(self, reg, frontier_val, tie_policy): + return override_set_b(reg, frontier_val, tie_policy) + + def _compact(self, reg, frontier_val, tie_policy): + return compact_b(reg, frontier_val, tie_policy) + + def _merge_reg(self, a, b): + return merge_reg_b(a, b) + + def _bump(self, s, c): + return max(s, c) + 1 + + def _sanitize_value(self, v): + return isinstance(v, int) and 0 <= v <= 4294967295 + + def override_is_set(self, ctx, tie_policy=CLEAR): + return self._override_set( + self.overrides.get(ctx), self.effective_frontier(ctx), tie_policy + ) + + def verdict(self, ctx, latest_ts, tie_policy=CLEAR): + return (latest_ts > self.effective_frontier(ctx) + or self.override_is_set(ctx, tie_policy)) + + def do_mark_unread(self, ctx): + if self.is_legacy: + return + cur = self.overrides.get(ctx, RegB()) + new_s = self._bump(cur.s, cur.c) + self.overrides[ctx] = RegB(s=new_s, c=cur.c, b=self.effective_frontier(ctx)) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + cur = self.overrides.get(ctx, RegB()) + new_c = self._bump(cur.s, cur.c) + self.overrides[ctx] = RegB(s=cur.s, c=new_c, b=cur.b) + + def do_advance_frontier(self, ctx, ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + + def do_compact(self, ctx, tie_policy=CLEAR): + reg = self.overrides.get(ctx) + if reg is None: + return + result = self._compact(reg, self.effective_frontier(ctx), tie_policy) + if result is None: + if ctx in self.overrides: + del self.overrides[ctx] + else: + self.overrides[ctx] = result + + def do_reinstall(self): + self.client_id = self.client_id + "_r" + self.frontier = {} + self.overrides = {} + + def _canonicalize_for_publish(self, ctx, tie_policy): + """Canonical published form of `ctx`'s override register, + computed fresh against the current effective frontier — + independent of whether `do_compact` was ever called locally. + Returns `(is_live, canonical_reg)`; `canonical_reg is None` + means virgin (omit from the wire entirely). Reuses the same + overridable `_compact`/`_override_set` hooks `do_compact` uses, + so a mutation-harness subclass that weakens one weakens both + the storage-GC path and the publish path identically. + """ + reg = self.overrides.get(ctx) + if reg is None: + return False, None + front = self.effective_frontier(ctx) + canonical = self._compact(reg, front, tie_policy) + if canonical is None: + return False, None + return self._override_set(canonical, front, tie_policy), canonical + + def publish_blob(self, tie_policy=CLEAR): + """Serialize this device's read-state blob. + + Every override is canonicalized at serialization time: live -> + unchanged (3 keys), dead -> tombstone floor (1 key, `ov_c:` + only), virgin -> omitted (0 keys). Canonical publication is a + protocol requirement, not an optimization — noncanonical wire + output is structurally impossible here, not merely avoided by + convention. `do_compact` remains a separate storage-GC + transition that mutates `self.overrides`; publication no + longer depends on it having been called first. + + **Atomic slot-grouping rule (spec-amendment requirement):** + A context's frontier entry and ALL of its `ov_*` sibling entries + MUST travel in the same slot. `split_blob_into_slots` below + enforces this by round-robining per-context groups, never + per-entry. A receiving client that only holds part of a context + group and attempts to reconstruct a `RegB` from it would see + partial zeroes and might canonically re-publish a false + tombstone. Group atomicity makes partial reconstruction + structurally impossible from a compliant publisher's output. + """ + blob_ctx = {escape_context_key(k): v for k, v in self.frontier.items()} + if not self.is_legacy: + for k in self.overrides: + is_live, canonical = self._canonicalize_for_publish(k, tie_policy) + if canonical is None: + continue # virgin: omitted from the wire entirely + if is_live: + blob_ctx[f"ov_s:{k}"] = canonical.s + blob_ctx[f"ov_c:{k}"] = canonical.c + blob_ctx[f"ov_b:{k}"] = canonical.b + else: + blob_ctx[f"ov_c:{k}"] = canonical.c # tombstone: ceiling only + return {"v": 1, "client_id": self.client_id, "contexts": blob_ctx} + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split this device's blob into `n_slots` compliant slots. + + **Atomic grouping rule:** a context's frontier entry and ALL of + its `ov_*` sibling entries travel together in the same slot. + Round-robin assignment is per-context group, never per-entry. + This matches production `splitContextsIntoBudgetedSlots` when + it is amended to group by context instead of by individual entry. + + Returns a list of `n_slots` blobs, each with the same `v` and + `client_id` but a disjoint subset of context groups. + """ + blob = self.publish_blob(tie_policy) + contexts = blob["contexts"] + + # Gather per-context groups: each group is a list of (key, value) pairs. + # A "group" is: the frontier key (escaped ctx) + any ov_* siblings. + # Contexts that appear only as ov_* keys (no frontier entry) are + # also grouped together. + groups = {} # logical_ctx -> list of (wire_key, value) + for wire_key, value in contexts.items(): + if wire_key.startswith("ov_s:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_c:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_b:"): + ctx = wire_key[5:] + else: + # Frontier key: may be escaped (e.g. "esc:ov_s:evil"). + # Derive the logical context ID by unescaping so this + # entry joins the same group as its ov_* siblings, which + # are keyed by the RAW context ID (e.g. "ov_s:evil" -> + # ctx = "evil", but "esc:ov_s:evil" frontier -> ctx = + # "ov_s:evil" after unescape). Without this step an + # escaped frontier key and its ov_* siblings would be + # treated as two different groups, splitting the register + # across slots — reproducing the round-1 partial- + # reconstruction poison for escaped context IDs. + ctx = unescape_context_key(wire_key) + groups.setdefault(ctx, []).append((wire_key, value)) + + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for i, (_ctx, pairs) in enumerate(sorted(groups.items())): + slot = slots[i % n_slots] + for wire_key, value in pairs: + slot["contexts"][wire_key] = value + return slots + + def receive_merge(self, blob): + incoming_overrides = {} + for k, v in blob.get("contexts", {}).items(): + if k.startswith("ov_s:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[0] = v + elif k.startswith("ov_c:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[1] = v + elif k.startswith("ov_b:"): + ctx = k[5:] + incoming_overrides.setdefault(ctx, [0, 0, 0])[2] = v + else: + ctx = unescape_context_key(k) + self.frontier[ctx] = max(self.frontier.get(ctx, 0), v) + + if not self.is_legacy: + for ctx, (s, c, b) in incoming_overrides.items(): + incoming_reg = RegB(s=s, c=c, b=b) + self.overrides[ctx] = self._merge_reg( + self.overrides.get(ctx), incoming_reg + ) + + def legacy_sanitize_and_publish(self, tie_policy=CLEAR): + blob = self.publish_blob(tie_policy) + sanitized = {} + for k, v in blob["contexts"].items(): + if len(k.encode("utf-8")) <= 256 and self._sanitize_value(v): + sanitized[k] = v + return {"v": 1, "client_id": self.client_id, "contexts": sanitized} + + def state_key(self, contexts, tie_policy=CLEAR): + parts = [] + for ctx in sorted(contexts): + f = self.effective_frontier(ctx) + reg = self.overrides.get(ctx, RegB()) + ov = self.override_is_set(ctx, tie_policy) + parts.append((ctx, f, reg.s, reg.c, reg.b, ov)) + return (self.client_id, self.is_legacy, tuple(parts)) + + +# --------------------------------------------------------------------------- +# Device simulation — Candidate A +# --------------------------------------------------------------------------- + +class DeviceA: + def __init__(self, client_id, is_legacy=False): + self.client_id = client_id + self.is_legacy = is_legacy + self.frontier = {} + self.overrides = {} + self.counter = 0 + + def effective_frontier(self, ctx): + return self.frontier.get(ctx, 0) + + def override_is_set(self, ctx): + reg = self.overrides.get(ctx) + if reg is None or reg.op == CLEAR: + return False + if self.effective_frontier(ctx) > reg.baseline: + return False + return True + + def verdict(self, ctx, latest_ts): + return latest_ts > self.effective_frontier(ctx) or self.override_is_set(ctx) + + def do_mark_unread(self, ctx): + if self.is_legacy: + return + self.counter += 1 + self.overrides[ctx] = RegA( + counter=self.counter, tiebreak=self.client_id, + op=SET, baseline=self.effective_frontier(ctx), + ) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + self.counter += 1 + self.overrides[ctx] = RegA( + counter=self.counter, tiebreak=self.client_id, + op=CLEAR, baseline=0, + ) + + def do_advance_frontier(self, ctx, ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + + def receive_merge(self, blob, tie_op=CLEAR): + for ctx, ts in blob.get("contexts", {}).items(): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), ts) + if not self.is_legacy: + for ctx, reg in blob.get("overrides", {}).items(): + self.overrides[ctx] = merge_reg_a( + self.overrides.get(ctx), reg, tie_op + ) + if reg.counter > self.counter: + self.counter = reg.counter + + def publish_blob(self): + blob = {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)} + if not self.is_legacy: + blob["overrides"] = dict(self.overrides) + return blob + + def legacy_rewrite_and_publish(self): + return {"v": 1, "client_id": self.client_id, "contexts": dict(self.frontier)} + + +# --------------------------------------------------------------------------- +# Legacy pruning/trim model +# --------------------------------------------------------------------------- + +def legacy_prune(contexts, horizon): + return {k: v for k, v in contexts.items() + if not (k.startswith("msg:") or k.startswith("thread:")) or v >= horizon} + + +def legacy_trim(contexts, client_id, max_bytes=32768): + import json + + def size(ctx): + return len(json.dumps({"v": 1, "client_id": client_id, "contexts": ctx}).encode()) + + if size(contexts) <= max_bytes: + return contexts, True + evictable = sorted( + ((k, v) for k, v in contexts.items() + if k.startswith("msg:") or k.startswith("thread:")), + key=lambda kv: kv[1], + ) + out = dict(contexts) + for k, _ in evictable: + del out[k] + if size(out) <= max_bytes: + return out, True + return out, size(out) <= max_bytes + + +def legacy_sanitize_blob(blob): + sanitized = {} + for k, v in blob.get("contexts", {}).items(): + if (len(k.encode("utf-8")) <= 256 + and isinstance(v, int) and 0 <= v <= 4294967295): + sanitized[k] = v + return {"v": 1, "client_id": blob.get("client_id", ""), "contexts": sanitized} diff --git a/docs/formal/nip-rs-unread/mutation.py b/docs/formal/nip-rs-unread/mutation.py new file mode 100644 index 0000000000..4450a99c8f --- /dev/null +++ b/docs/formal/nip-rs-unread/mutation.py @@ -0,0 +1,519 @@ +"""Mutation harness for candidate B (two-counter) model. + +Each mutant: subclass DeviceB with a weakened rule, run the BFS explorer, +require a recorded counterexample. A model that stays green under a real +weakening is worthless. + +Mutants: + M1: drop baseline dominance (frontier > B no longer clears stale set) + M2: drop max(S,C)+1 bump (use S+1 or C+1 — counter can regress) + M3: flip tie policy (verify the model distinguishes them) + M4: revert to delete-on-dominance compaction (drops the tombstone floor + entirely instead of zeroing S and keeping max(S,C) as C) — reproduces + Thufir's pass-3 CRITICAL: stale-replay resurrection after counter reuse + M5: uint32 overflow bypass (legacy sanitization disabled) + M6: componentwise-max -> last-write-wins merge (convergence breaks) + M7: publish without canonicalization (serialize raw registers instead + of the compact-at-publish canonical form) — reproduces Thufir's + pass-1/2 CRITICAL: dead+dead merge resurrection + M8: revert split_blob_into_slots to per-entry splitting (violates the + atomic-grouping rule) — reproduces Thufir's pass-2/2 CRITICAL: + partial-slot reconstruction of a live RegB creates a false tombstone + that permanently suppresses the override after eventual full delivery + M9: revert split_blob_into_slots to escaped-key grouping (groups frontier + by wire key instead of unescaped logical ID) — reproduces Thufir's + round-2 CRITICAL: for a context whose raw ID starts with a reserved + prefix (e.g. "ov_s:evil"), the frontier's escaped wire key + ("esc:ov_s:evil") and the ov_* siblings (keyed by raw suffix "ov_s:evil") + resolve to different groups → register split across slots → + old/new slot-coordinate mixture produces partial reconstruction → + false tombstone → permanent false clear across publication cycles + +Each mutant is injected into the model via DeviceB subclass, then the +explorer or invariant suite is rerun. The counterexample (first violation) +is recorded and printed. +""" +from copy import deepcopy +from model import ( + RegB, merge_reg_b, override_set_b, compact_b, + DeviceB, legacy_sanitize_blob, + escape_context_key, + SET, CLEAR, +) +from exhaustive import ( + explore_b, test_concurrent_stability, + test_compaction_register_exhaustive, test_deep_history_compaction, + test_published_merge_closure, test_interleaved_delivery_grouping, + test_escaped_context_slot_grouping, + CONTEXTS, +) + + +# --------------------------------------------------------------------------- +# M1: drop baseline dominance +# --------------------------------------------------------------------------- + +class M1_NoBaselineDominance(DeviceB): + def _override_set(self, reg, frontier_val, tie_policy): + if reg is None: + return False + if reg.s > reg.c: + return True + if reg.s == reg.c and reg.s > 0: + return tie_policy == SET + return False + + def _compact(self, reg, frontier_val, tie_policy): + if reg.s == 0 and reg.c == 0: + return None + if self._override_set(reg, frontier_val, tie_policy): + return reg + if reg.c > reg.s: + return RegB(s=0, c=reg.c, b=0) + if reg.c == reg.s and tie_policy == CLEAR: + return RegB(s=0, c=reg.c, b=0) + return reg + + +def mutant_m1(): + """M1: without baseline dominance, a stale set persists after frontier + advance past baseline. Verify by constructing the scenario directly: + mark-unread at frontier=10, then advance frontier to 100. The correct + model clears the override; the mutant keeps it live.""" + violations = [] + for ctx in CONTEXTS: + dev = M1_NoBaselineDominance("d0") + dev.frontier[ctx] = 10 + dev.do_mark_unread(ctx) + dev.do_advance_frontier(ctx, 100) + + correct = override_set_b(dev.overrides[ctx], 100, CLEAR) + mutant_result = dev.override_is_set(ctx, CLEAR) + + if correct != mutant_result: + violations.append(( + "baseline-dominance-missing", ctx, + dev.overrides[ctx], 100, + f"correct={correct}", f"mutant={mutant_result}", + )) + + if not violations: + _, violations = explore_b(max_depth=3, tie_policy=CLEAR, + device_cls=M1_NoBaselineDominance) + return violations + + +# --------------------------------------------------------------------------- +# M2: drop max(S,C)+1 bump +# --------------------------------------------------------------------------- + +class M2_NoBump(DeviceB): + """Each counter bumps only itself: mark_unread does S := S+1, + mark_read does C := C+1. When S > C from a prior set, a clear + at C+1 can produce C < S even though the clear is causally later.""" + def do_mark_unread(self, ctx): + if self.is_legacy: + return + cur = self.overrides.get(ctx, RegB()) + self.overrides[ctx] = RegB(s=cur.s + 1, c=cur.c, + b=self.effective_frontier(ctx)) + + def do_mark_read(self, ctx, frontier_ts): + self.frontier[ctx] = max(self.frontier.get(ctx, 0), frontier_ts) + if not self.is_legacy: + cur = self.overrides.get(ctx, RegB()) + self.overrides[ctx] = RegB(s=cur.s, c=cur.c + 1, b=cur.b) + + +def mutant_m2(): + """M2: each counter bumps independently. After set→set→clear at + the SAME frontier (no advance past baseline): correct clear has + C=3 > S=2, mutant clear has C=1 < S=2 — a causally later clear + fails to dominate. + + Use mark_read at the current frontier (not advancing past baseline) + so baseline dominance doesn't mask the counter discrepancy. + """ + violations = [] + for ctx in CONTEXTS: + front = 10 + dev_correct = DeviceB("d0") + dev_correct.frontier[ctx] = front + dev_correct.do_mark_unread(ctx) + dev_correct.do_mark_unread(ctx) + dev_correct.do_mark_read(ctx, front) + + dev_mutant = M2_NoBump("d0") + dev_mutant.frontier[ctx] = front + dev_mutant.do_mark_unread(ctx) + dev_mutant.do_mark_unread(ctx) + dev_mutant.do_mark_read(ctx, front) + + correct_set = dev_correct.override_is_set(ctx, CLEAR) + mutant_set = dev_mutant.override_is_set(ctx, CLEAR) + + if correct_set != mutant_set: + violations.append(( + "bump-independent", ctx, + f"correct={dev_correct.overrides[ctx]}", + f"mutant={dev_mutant.overrides[ctx]}", + f"correct_set={correct_set}", f"mutant_set={mutant_set}", + )) + + if not violations: + _, violations = explore_b(max_depth=4, tie_policy=CLEAR, + device_cls=M2_NoBump) + return violations + + +# --------------------------------------------------------------------------- +# M3: tie policy distinguishable +# --------------------------------------------------------------------------- + +def mutant_m3(): + """M3: tie policy is load-bearing — S==C must produce different verdicts. + Not a DeviceB mutation; tests the model function directly.""" + reg = RegB(s=1, c=1, b=10) + frontier = 10 + v_clear = override_set_b(reg, frontier, CLEAR) + v_set = override_set_b(reg, frontier, SET) + if v_clear == v_set: + return [] + return [("tie-distinguishable", v_clear, v_set, reg, frontier)] + + +# --------------------------------------------------------------------------- +# M4: revert to delete-on-dominance compaction (drops the tombstone floor) +# --------------------------------------------------------------------------- + +class M4_DeleteOnDominance(DeviceB): + """The pre-fix compaction rule: any dead/dominated register is deleted + entirely rather than reduced to the tombstone floor RegB(0, max(S,C), 0). + This makes counters reusable — a later local set/clear pair restarts + from S=0/C=0, so a delayed stale peer snapshot can dominate it on + replay. This is exactly the rule Thufir's pass-3 CRITICAL found live + at e453b3945.""" + def _compact(self, reg, frontier_val, tie_policy): + if reg.s == 0 and reg.c == 0: + return None + if self._override_set(reg, frontier_val, tie_policy): + return reg + if frontier_val > reg.b: + return None + if reg.c > reg.s: + return RegB(s=0, c=reg.c, b=0) + if reg.c == reg.s and tie_policy == CLEAR: + return RegB(s=0, c=reg.c, b=0) + return reg + + +def mutant_m4(): + """M4: without the tombstone floor, compaction deletes the counter + ceiling instead of preserving it. Reproduce Thufir's exact witness + directly: RegB(3,0,10) at frontier=20 compacts to None under the old + rule (vs. RegB(0,3,0) under the fix); a subsequent local set+clear + reuses counters from zero; the stale ancestor then replays and + resurrects (S>C) under both tie policies. + + Then confirm the explorer/deep-history suite also catches it (defense + in depth — a mutant that only fails a hand-built scenario would still + be a real bug, but the directed check is what's supposed to catch this + class per T2/T3).""" + violations = [] + stale = RegB(s=3, c=0, b=10) + frontier_after = 20 + + for tie_policy in (CLEAR, SET): + dev = M4_DeleteOnDominance("d0") + dev.frontier["c0"] = 10 + dev.overrides["c0"] = stale + dev.do_advance_frontier("c0", frontier_after) + dev.do_compact("c0", tie_policy) + if "c0" in dev.overrides: + continue # old rule didn't drop it here; not the witness shape + + dev.do_mark_unread("c0") # S := 1, B := 20 + dev.do_mark_read("c0", frontier_after) # C := 2 + + stale_blob = {"contexts": {"ov_s:c0": stale.s, "ov_c:c0": stale.c, "ov_b:c0": stale.b}} + dev.receive_merge(stale_blob) + resurrected = dev.override_is_set("c0", tie_policy) + + if resurrected: + violations.append(( + "M4-delete-on-dominance-resurrection", tie_policy, + f"stale_ancestor={stale}", f"post_compact_reuse=(set,clear)", + f"final_reg={dev.overrides['c0']}", f"override_is_set={resurrected}", + )) + + if not violations: + _, violations = test_deep_history_compaction(device_cls=M4_DeleteOnDominance) + return violations + + +# --------------------------------------------------------------------------- +# M5: uint32 overflow bypass +# --------------------------------------------------------------------------- + +def mutant_m5(): + """M5: values outside uint32 range must fail legacy sanitization.""" + blob = {"v": 1, "client_id": "x", "contexts": { + "ov_s:c0": 4294967296, + "ov_c:c0": 0, + "ov_b:c0": 10, + }} + sanitized = legacy_sanitize_blob(blob) + if "ov_s:c0" in sanitized["contexts"]: + return [] + return [("overflow-rejected", blob["contexts"]["ov_s:c0"], + sanitized["contexts"])] + + +# --------------------------------------------------------------------------- +# M6: last-write-wins merge (breaks convergence) +# --------------------------------------------------------------------------- + +class M6_LastWriteWins(DeviceB): + def _merge_reg(self, a, b): + if a is None: + return b + if b is None: + return a + return b + + +def mutant_m6(): + """M6: replace componentwise max with last-write-wins. Convergence must + break — different delivery orders produce different final states.""" + _, violations = explore_b(max_depth=3, tie_policy=CLEAR, + device_cls=M6_LastWriteWins) + return violations + + +# --------------------------------------------------------------------------- +# M7: publish without canonicalization (reproduces Thufir's pass-1/2 +# CRITICAL — dead+dead merge resurrection) +# --------------------------------------------------------------------------- + +class M7_PublishWithoutCanonicalization(DeviceB): + """Reverts `publish_blob` to serialize raw, uncompacted registers — + the exact pre-fix behavior Thufir's pass-1/2 CRITICAL exploited: + a dead register's baseline-relative death (or clear-count-relative + death) never gets folded into a globally-comparable ceiling before + hitting the wire, so two individually-dead registers can + componentwise-max-merge into a live join.""" + def publish_blob(self, tie_policy=CLEAR): + blob_ctx = {escape_context_key(k): v for k, v in self.frontier.items()} + if not self.is_legacy: + for k, reg in self.overrides.items(): + blob_ctx[f"ov_s:{k}"] = reg.s + blob_ctx[f"ov_c:{k}"] = reg.c + blob_ctx[f"ov_b:{k}"] = reg.b + return {"v": 1, "client_id": self.client_id, "contexts": blob_ctx} + + +def mutant_m7(): + """M7: publish-without-canonicalization must be caught by the + published-state merge-closure invariant — proving that invariant + has teeth. Reproduce Thufir's exact witness directly first (fast, + deterministic); fall back to the full search if the hand-built + scenario doesn't trigger under a given tie policy.""" + violations = [] + for tie_policy in (CLEAR, SET): + dev_a = M7_PublishWithoutCanonicalization("a") + dev_a.frontier["c0"] = 50 + dev_a.overrides["c0"] = RegB(s=3, c=2, b=0) + dev_b = M7_PublishWithoutCanonicalization("b") + dev_b.frontier["c0"] = 100 + dev_b.overrides["c0"] = RegB(s=1, c=2, b=100) + + blob_a = dev_a.publish_blob(tie_policy) + blob_b = dev_b.publish_blob(tie_policy) + + for first, second in [(blob_a, blob_b), (blob_b, blob_a)]: + recv = M7_PublishWithoutCanonicalization("recv") + recv.receive_merge(first) + recv.receive_merge(second) + if recv.override_is_set("c0", tie_policy): + violations.append(( + "M7-publish-without-canonicalization-resurrection", + tie_policy, blob_a, blob_b, recv.overrides["c0"], + )) + + if not violations: + _, violations = test_published_merge_closure( + device_cls=M7_PublishWithoutCanonicalization + ) + return violations + + +# --------------------------------------------------------------------------- +# M8: revert split_blob_into_slots to per-entry splitting +# (violates the atomic-grouping rule — reproduces Thufir's pass-2/2 CRITICAL) +# --------------------------------------------------------------------------- + +class M8_PerEntrySplit(DeviceB): + """Reverts `split_blob_into_slots` to a per-entry split that violates the + atomic-grouping rule by separating `ov_s:` + frontier from `ov_b:` + `ov_c:`. + + This reproduces Thufir's exact transport witness: + - Slot 0: frontier key + `ov_s:` entry (the "partial set" slot) + - Slot 1: `ov_c:` + `ov_b:` entries + + An observer receiving only slot 0 reconstructs `RegB(s=1, c=0, b=0)` at + `frontier=10`. Because `frontier(10) > b(0)`, the override is baseline-dead. + Canonical re-publication emits tombstone `RegB(0, 1, 0)`. After full + eventual delivery (both original slots + transient tombstone), the merged + result is `RegB(s=1, c=1, b=10)` — dead under clear-wins — permanently + suppressing a live override. + """ + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split by key type: frontier + ov_s: in slot 0, ov_b: + ov_c: in slot 1. + Violates the atomic-grouping rule by separating ov_s: from ov_b:.""" + blob = self.publish_blob(tie_policy) + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for wire_key, value in blob["contexts"].items(): + if wire_key.startswith("ov_b:") or wire_key.startswith("ov_c:"): + # ov_b and ov_c go to slot 1 — separated from their ov_s: sibling + slots[1]["contexts"][wire_key] = value + else: + # frontier keys and ov_s: go to slot 0 + slots[0]["contexts"][wire_key] = value + return slots + + +def mutant_m8(): + """M8: per-entry splitting must be caught by test_interleaved_delivery_grouping — + proving that the new interleaved-delivery test has teeth. + + Reproduce Thufir's exact transport witness directly: source live + `RegB(1,0,10)` at frontier=10. Per-entry split puts frontier+`ov_s:c0` + in slot 0 and `ov_c:c0`+`ov_b:c0` in slot 1. An observer receiving only + slot 0 reconstructs `RegB(1,0,0)`, re-publishes tombstone `RegB(0,1,0)`. + Full merge including the transient: `RegB(1,1,10)` → inactive. + + Confirmed by running test_interleaved_delivery_grouping with M8_PerEntrySplit; + the witness must be caught before resorting to the full suite.""" + return test_interleaved_delivery_grouping(device_cls=M8_PerEntrySplit) + + +# --------------------------------------------------------------------------- +# M9: revert split_blob_into_slots to escaped-key grouping +# (groups frontier by wire key instead of unescaped logical ID — +# reproduces Thufir's round-2 CRITICAL) +# --------------------------------------------------------------------------- + +class M9_EscapedKeyGrouping(DeviceB): + """Reverts `split_blob_into_slots` to group the frontier key by its + ESCAPED wire key rather than the unescaped logical context ID. + + For a normal context like "c0", this is a no-op (escape_context_key("c0") + == "c0"), so M9 is identical to the correct model on normal contexts. + The defect only manifests when the raw context ID starts with a reserved + prefix — e.g. raw "ov_s:evil" escapes to frontier wire key "esc:ov_s:evil". + The ov_* sibling keys are keyed by the RAW suffix ("ov_s:evil"), while + the frontier is keyed by the escaped wire key ("esc:ov_s:evil") — two + identities for one logical context, so they land in different slots. + + This reproduces Thufir's round-2 CRITICAL: across publication cycles an + observer can receive the new frontier slot (esc:ov_s:evil=10) plus the + stale old-cycle override slot (ov_s/ov_c/ov_b at b=0), reconstructing + RegB(s=1,c=0,b=0) at frontier=10 — baseline-dead — and emitting tombstone + RegB(0,1,0). Full eventual delivery merges to RegB(1,1,10) — dead under + clear-wins — permanently suppressing a live override. + """ + + def split_blob_into_slots(self, tie_policy=CLEAR, n_slots=2): + """Split by original (escaped) wire key identity — does not unescape + frontier keys before grouping, so escaped contexts split incorrectly.""" + blob = self.publish_blob(tie_policy) + contexts = blob["contexts"] + + groups = {} # wire_key -> list of (wire_key, value) + for wire_key, value in contexts.items(): + if wire_key.startswith("ov_s:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_c:"): + ctx = wire_key[5:] + elif wire_key.startswith("ov_b:"): + ctx = wire_key[5:] + else: + ctx = wire_key # frontier: use escaped wire key as group ID (BUG) + groups.setdefault(ctx, []).append((wire_key, value)) + + slots = [{"v": blob["v"], "client_id": blob["client_id"], "contexts": {}} + for _ in range(n_slots)] + for i, (_ctx, pairs) in enumerate(sorted(groups.items())): + slot = slots[i % n_slots] + for wire_key, value in pairs: + slot["contexts"][wire_key] = value + return slots + + +def mutant_m9(): + """M9: escaped-key grouping must be caught by test_escaped_context_slot_grouping — + proving that the escaped-context regression test has teeth. + + For a context whose raw ID starts with a reserved prefix ("ov_s:evil"), + the frontier wire key is "esc:ov_s:evil" and the ov_* sibling keys are + "ov_s:ov_s:evil", "ov_c:ov_s:evil", "ov_b:ov_s:evil". The escaped-key + grouping treats "esc:ov_s:evil" (frontier) and "ov_s:evil" (ov_* suffix) + as different groups, splitting the register across slots. + + Old/new slot-coordinate mixture across publication cycles then reproduces + the round-1 transport poison: partial reconstruction → false tombstone → + permanent false clear of a live override. + + The test is parameterized to route through the "mismatched grouping" path + (else branch) when the M9 split puts frontier and siblings in different slots, + and the witness must be caught.""" + return test_escaped_context_slot_grouping(device_cls=M9_EscapedKeyGrouping) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +def run_mutations(): + mutants = [ + ("M1: drop baseline dominance", mutant_m1), + ("M2: drop max(S,C)+1 bump", mutant_m2), + ("M3: tie policy distinguishable", mutant_m3), + ("M4: revert to delete-on-dominance compaction (reproduces pass-3 CRITICAL)", mutant_m4), + ("M5: uint32 overflow bypass", mutant_m5), + ("M6: last-write-wins merge", mutant_m6), + ("M7: publish without canonicalization (reproduces pass-1/2 CRITICAL)", mutant_m7), + ("M8: per-entry split violates atomic-grouping rule (reproduces pass-2/2 CRITICAL)", mutant_m8), + ("M9: escaped-key grouping splits escaped-ctx register across slots (reproduces round-2 CRITICAL)", mutant_m9), + ] + + print("=" * 60) + print("Mutation harness — candidate B") + print("=" * 60) + + caught = [] + missed = [] + for name, fn in mutants: + violations = fn() + if violations: + caught.append(name) + v = violations[0] + detail = str(v)[:200] + print(f" CAUGHT: {name}") + print(f" counterexample: {detail}") + else: + missed.append(name) + print(f" MISSED: {name}") + + print(f"\nCaught {len(caught)}/{len(mutants)} mutants") + if missed: + print(f"MISSED: {missed}") + print("=" * 60) + return len(missed) == 0 + + +if __name__ == "__main__": + import sys + sys.exit(0 if run_mutations() else 1) diff --git a/docs/nips/NIP-RS.md b/docs/nips/NIP-RS.md index 1ca30ea08f..6a095df60a 100644 --- a/docs/nips/NIP-RS.md +++ b/docs/nips/NIP-RS.md @@ -20,15 +20,14 @@ read. A user running Nostr clients on multiple devices (phone, desktop, web) has no way to share read position across those clients. Each instance independently tracks what has been read, causing already-read content to appear unread on other devices. -This NIP defines a minimal, privacy-preserving protocol for propagating read state across client instances without requiring relay-side logic or coordination between different client implementations. +This NIP defines a minimal, privacy-preserving protocol for propagating read state across client instances without requiring a new event kind, a new wire message, relay-stored read-state logic, or coordination between different client implementations. It is not free of relay obligations: a relay serving the manual-unread override layer's full-state load must satisfy the ordering, capacity, floor, push, and barrier contract that section enumerates. ## Non-Goals -This NIP does not define a durable log of all read messages — blobs are best-effort recent activity hints bounded by a time horizon. +This NIP does not define a durable log of all read messages — frontier blobs are best-effort recent activity hints bounded by a time horizon. Exception: `ov_*` override entries, including tombstone floors, are durable state — they are exempt from age pruning, budget eviction, and horizon-bounded fetching, they live in a single coordinate per installation, and they MUST be carried forward before that coordinate is deleted or abandoned (see Manual-Unread Override Layer — Override State Durability). This NIP does not define cross-client interoperability on context ID format — context identifiers are opaque by default and meaningful only within a single client family, except for OPTIONAL well-known schemes defined in this NIP (`thread:` and `msg:`, defined under Read Context Schemes), which are provided for cross-client thread/message-read interoperability. -This NIP does not define mark-as-unread — the merge rule is monotonic by design. This NIP does not guarantee ordering of read events across devices. -This NIP does not require relay-side logic. +This NIP does not require relay-stored read-state logic: no new event kind, no new wire message, and nothing a relay must interpret about read state. Clients implementing the manual-unread override layer do depend on relay behaviour their full-state load cannot verify (see Full-State Load). This NIP does not define read receipts, seen-by lists, or any mechanism for tracking what other users have read. @@ -53,18 +52,26 @@ Clients publish a `kind:30078` addressable event (per [NIP-78](78.md)) with the #### `d` Tag -The `d` tag MUST be `read-state:`, where `` is a random opaque string (e.g., 32 random hex characters) generated by the client on first launch and persisted locally. The `` has no relationship to the `client_id` — it is solely a unique key for NIP-33 addressable event semantics. Each client instance MUST use a stable, unique `` for the lifetime of that installation. +The `d` tag MUST be `read-state:`, where `` is exactly 32 lowercase hexadecimal characters (`[0-9a-f]{32}`), generated randomly by the client on first launch and persisted locally. The `` has no relationship to the `client_id` — it is solely a unique key for NIP-33 addressable event semantics. The shape is fixed rather than opaque so that a relay can recognize a read-state coordinate structurally, from the `d` tag alone and without decrypting anything, and apply per-coordinate protections to it; a client that picks some other shape is not merely stylistically different, it forfeits those protections silently. + +**Primary coordinate:** a client MUST designate one coordinate as its **primary** and MUST use a single stable, unique `` for it for the lifetime of that installation. The primary `` changes only on a `client_id` conflict (below) or rotation (see Client-ID Rotation). + +**Additional frontier-only coordinates:** a client MAY publish additional coordinates under distinct `` values when its primary blob would otherwise exceed the size budget. Additional coordinates MUST NOT contain `ov_*` entries — they carry frontier entries only, and are therefore freely rewritable and freely deletable (see Orphaned Blob Deletion). A client MUST persist the `` values of its additional coordinates locally so that it can rewrite and delete them. + +**All `ov_*` entries, and the frontier entries of the contexts they belong to, MUST live in the primary coordinate.** A client implementing the manual-unread override layer MUST NOT distribute `ov_*` entries across coordinates and MUST NOT move them between coordinates: there is exactly one override-bearing coordinate per installation. If a client fetches its own `d` tag coordinate and the decrypted `client_id` does not match its local `client_id`, the coordinate is conflicted. The client MUST NOT publish to that coordinate and MUST generate a new random `` before the next publish. Events with zero `d` tags MUST be ignored. Events whose `d` tag value does not begin with `read-state:` MUST be ignored. Events with more than one `d` tag MUST be ignored. -The `` MUST be a non-empty ASCII string of 1–64 characters. +Events whose `` is not exactly 32 lowercase hexadecimal characters MUST be ignored. + +Recognizable coordinates also serve the accumulation discipline this NIP depends on: a relay that can identify a read-state coordinate structurally can replace superseded versions outright instead of retaining a tombstone row per publish, which keeps the coordinate count a full-state load must enumerate near one per live installation (see Full-State Load). #### `t` Tag -Events MUST include exactly one `["t", "read-state"]` tag. This enables relay-side filtering without fetching all `kind:30078` events for the user. +Events MUST include exactly one `["t", "read-state"]` tag. The tag is a discoverability marker: it lets a client express "read-state events only" in a single filter. It is not a guarantee of relay-side selectivity — a relay MAY apply tag constraints after its result cap, and `kind:30078` is shared with unrelated application data — so clients MUST apply the tag as a correctness filter locally on everything they receive, and MUST NOT infer from a short result that no further coordinates exist. A client performing a full-state load MUST omit the tag from its filter entirely (see Full-State Load). Events with zero `t` tags with value `read-state`, or more than one `t` tag with value `read-state`, MUST be ignored. @@ -104,6 +111,7 @@ After decryption, clients MUST apply the following validation rules: - Events whose `contexts` field is not a JSON object MUST be discarded. - Individual context entries whose timestamp is not an integer in the range 0–4294967295 MUST be discarded (the entry is dropped; the rest of the blob is still processed). - Individual context entries whose context ID exceeds 256 bytes MUST be discarded. +- Override counter entries (keys beginning with `ov_s:`, `ov_c:`, or `ov_b:`) MUST be validated as a complete logical group BEFORE any decoding, zero-filling, merging, or canonicalizing. Clients MUST collect all `ov_s:`, `ov_c:`, and `ov_b:` entries for the same `` suffix together before processing them. The only accepted wire shapes for an override group are: (a) a complete live group containing exactly the three keys `ov_s:`, `ov_c:`, and `ov_b:` with valid uint32 values, or (b) a tombstone floor containing only `ov_c:` with a valid uint32 value. Any other shape (partial group, extra keys, or invalid value in any sibling) MUST cause the entire override group to be rejected; the corresponding frontier entry for `` MUST be retained. Applying the generic per-entry discard rule before group collection is prohibited for override entries. - Blobs containing more than 10,000 context entries MUST be rejected. - If a blob contains duplicate context keys, clients SHOULD use the last value encountered (consistent with RFC 8259 §4). - Clients SHOULD ensure the total serialized event does not exceed the relay's maximum event size (commonly 64 KB per NIP-01). Clients receiving events that exceed their configured size limit SHOULD discard them. @@ -112,6 +120,15 @@ After decryption, clients MUST apply the following validation rules: Context identifier format is not prescribed by this NIP. Clients choose identifiers appropriate to their context type (e.g., a NIP-28 channel event ID, a NIP-29 group address, a pubkey for DMs). Interoperability between different client implementations on context ID conventions is outside the scope of this NIP. +#### Reserved Namespace + +The key prefix stem `ov_` (3 bytes) and the escape marker `esc:` (4 bytes) are reserved for the manual-unread override layer defined below. Clients MUST escape any raw context ID that begins with `ov_` or `esc:` when using it as a frontier key in the `contexts` map: + +- **On publish:** prepend `esc:` to any raw context ID beginning with `ov_` or `esc:` before writing it as a frontier key (e.g., raw `ov_s:evil` → wire key `esc:ov_s:evil`; raw `esc:foo` → wire key `esc:esc:foo`). +- **On receive:** strip exactly one leading `esc:` from any frontier wire key beginning with `esc:` to recover the raw context ID (e.g., wire key `esc:ov_s:evil` → raw `ov_s:evil`; wire key `esc:esc:foo` → raw `esc:foo`). This is a bijection — applying escape then unescape is the identity function. Clients MUST NOT strip more than one `esc:` prefix per receive. + +**Backward-compatibility limitation:** a context published *unescaped* by a client predating this amendment, whose raw ID happens to start with `ov_` or `esc:`, is not safely migrated. The scheme protects contexts generated by amendment-aware clients going forward; it does not retroactively rewrite history. This residual hazard is documented as a known limitation. Buzz's own context ID shapes (channel UUID, `msg:hex64`, `thread:hex64`) cannot trigger it. + #### Read Context Schemes (Optional) This subsection defines OPTIONAL well-known context schemes for tracking read @@ -281,13 +298,13 @@ Because context timestamps are derived from message `created_at` values — whic ### Fetching -To load read state, a client MUST fetch all `kind:30078` events for the user within the time horizon using the `#t` filter: +To load read state, a client MUST fetch all `kind:30078` events for the user. Unless it is performing a full-state load (below), it SHOULD narrow the fetch with the `#t` filter: ```json -{"kinds": [30078], "authors": [""], "#t": ["read-state"], "since": } +{"kinds": [30078], "authors": [""], "#t": ["read-state"]} ``` -Clients SHOULD limit the fetch to events with `created_at` within a configurable time horizon (default: 7 days). +Clients that neither read nor write `ov_*` override state SHOULD limit the fetch to events with `created_at` within a configurable time horizon (default: 7 days) by adding `"since": `, accepting that frontiers older than the horizon become unknown. Clients that implement the manual-unread override layer MUST NOT filter the fetch by age or by tag, and MUST establish completeness (see Full-State Load below). After fetching, clients MUST: @@ -295,11 +312,71 @@ After fetching, clients MUST: 2. Discard blobs that fail validation (see Content Validation). 3. Identify the blob whose decrypted `client_id` matches the client's own `client_id` — this is the client's own blob. -If multiple blobs decrypt to the same `client_id` (e.g., due to a prior rotation that left an orphaned blob, or a backup/restore that duplicated identifiers), the client MUST treat the blob with the highest `created_at` as its own and merge all others into the read state as if they were from other instances. The client SHOULD delete the stale duplicate(s) via NIP-09 deletion. +If multiple blobs decrypt to the same `client_id` (e.g., due to a prior rotation that left an orphaned blob, or a backup/restore that duplicated identifiers), the client MUST treat the blob at its own primary coordinate as its own and merge all others into the read state as if they were from other instances. If none of them is at the client's own primary coordinate, the blob with the highest `created_at` is its current reference. Deletion of such a stale duplicate is governed by Orphaned Blob Deletion — a duplicate carrying `ov_*` entries MUST NOT be deleted until its override state has been carried forward. 4. Merge all valid blobs (including the client's own) using the merge rule. -Absence of a context in all fetched blobs means the read state for that context is **unknown** — clients SHOULD treat unknown contexts as unread (conservative default). The horizon is a storage and fetch optimization, not a semantic claim about read status. Contexts that were read but have aged out of the time horizon are indistinguishable from never-read contexts. Clients MAY extend the horizon or maintain a local cache to mitigate this. +Absence of a context in all fetched blobs means the read state for that context is **unknown** — clients SHOULD treat unknown contexts as unread (conservative default). For clients using a finite horizon, the horizon is a storage and fetch optimization, not a semantic claim about read status: contexts that were read but have aged out of the time horizon are indistinguishable from never-read contexts. Clients MAY extend the horizon or maintain a local cache to mitigate this. Clients implementing the override layer do not filter the fetch by age at all (see above), so for them this ambiguity arises only from write-time frontier pruning. + +#### Full-State Load + +Clients that implement the manual-unread override layer MUST perform a **full-state load**: they MUST NOT apply a finite `since` filter, and they MUST establish that every one of the user's `read-state` coordinates has been retrieved. Because the payload is encrypted, a relay filter cannot select for override-bearing events: any event-level window can exclude the only coordinate carrying a tombstone floor, which reopens the resurrection witness in Override State Durability regardless of any per-entry exemption. For these clients the time horizon is a *write-time* frontier pruning policy only (see Debounce and Pruning), never a fetch filter. + +Removing `since` does not by itself make the result complete. Relays MAY cap the number of events returned for a historical query, MAY cap below the client's requested `limit`, and emit end-of-stored-events after the capped query — so **a single query proves nothing.** End-of-stored-events marks the end of the capped result, not the end of the matching set, and a short result does not establish that no further coordinates exist. Caps typically retain the newest events and drop the oldest, which are precisely the rotation-predecessor and orphaned coordinates whose tombstone floors this layer depends on. A silently truncated load that omits the sole carrier of a floor merges a stale live register unopposed and reports a manually-unread context as read, permanently. + +A full-state load MUST therefore be enumerated with no tag constraint in the filter: + +```json +{"kinds": [30078], "authors": [""], "limit": } +``` + +A relay MAY deliver fewer events than its result cap selected — for example, by applying tag constraints only after the cap and withholding the events that fail them. Under a tag-constrained filter the number of events the client receives is therefore not the number the cap selected: a delivered page can be short, or empty, while older matching coordinates still exist below it, and no observation the client can make distinguishes the two. `kind:30078` is arbitrary application data whose `d` tag namespace is open to every application that has ever written under the user's key, so this is not a hypothetical — a page can be filled entirely by coordinates unrelated to read state. With the tag constraint omitted, the client asks for exactly what it will accept, and the events the cap selects are the events it receives. Selection moves client-side, which is where the validation rules already place it: collect coordinates only from `d` tags of the form `read-state:` and ignore every other event. The cost is that the client fetches its own application data at that kind rather than a relay-selected subset of it. + +A client MUST NOT test completeness by comparing the number of events returned against the `limit` it requested: the effective cap is the relay's, a relay MAY cap below the requested value, and a relay's advertised maximum limit is not necessarily the limit it enforces — so no comparison against the requested `limit` is a valid truncation test. What the client MAY compare is one delivery against another. Let `C` be the largest number of events the relay delivered for any single **preceding** query in this load. The relay demonstrably delivered `C` events at once, so its cap is at least `C`, and a query that delivers fewer than `C` events was not cut short by that cap. Completeness is established by continuation on a strictly decreasing cursor, with each band discharged by that comparison. + +`C` yields nothing at the start of a load, and yields nothing for the whole of a load whose entire history at this kind is a single event: one delivery of one event bounds the cap below by one, and no delivery can be smaller than that. The procedure therefore also fixes a floor, `L = 2`, required of relays below. A delivery is bounded by the requested `limit` as well as by the relay's cap, so the floor licenses a conclusion about a delivery only together with step 1's requirement that the requested `limit` be at least `L`: what the client may conclude is that a query it issued for at least `L` events, whose matching set holds at least `L`, delivers at least `L`. A threshold above the requested `limit` would be unreachable by construction, and a test that can never be met declares a truncated page exhausted. It is stated at the smallest value that admits a second event, because a larger floor is a stronger claim about relays that buys nothing further: a relay that will deliver only one event per query cannot express `limit` semantics and cannot serve a user who has two coordinates at all, whereas any floor above two would begin excluding relays this procedure does not need to exclude. + +Enumeration descends, so it cannot see a coordinate that moves *up* while it runs. Because these are addressable events, a republish replaces the previous version rather than appending: a coordinate the client already collected at a low `created_at` can be replaced, during the load, by a version above the cursor the client has already passed — and the old version stops existing, so no continuation and no pinned window will ever return either one. If that new version carries a tombstone floor the old one lacked, a load that reported *complete* merged without it. + +A full-state load therefore MUST be fenced by a live subscription on the same tag-free filter, established **before** the first enumeration query and held unbroken for the duration of the load. The fence is *established* when the client has received end-of-stored-events for that subscription, not when it sent the request: sending a request is not an observation, and the relay's answer to it is the first point at which the client knows the subscription is registered and that what the relay accepts from then on will be pushed to it. The fence and every enumeration query MUST be issued on the same connection. + +Every event the fence delivers is collected exactly as an enumerated event is (step 2), which is what repairs the moved coordinate: the replacing event is itself what the relay pushes. Delivery is necessary but not sufficient — it must be delivery *before the verdict*, and those are different properties. Under push delivery alone, a relay that accepts a replacement, removes the version sitting below the cursor, and pushes the replacement some time later has violated nothing: the enumeration in between finds neither version, the pinned window and the continuation both come back empty, and the load reports *complete* moments before the fence delivers the floor it was missing. Ordering that push ahead of the verdict is what the delivery barrier below requires of the relay. On the client side, the verdict MUST NOT be rendered until end-of-stored-events for the final continuation has been received and every fence delivery received before it has been collected. + +If the client did not hold such a subscription for the whole load, or it lapsed or reconnected at any point during it, the load is potentially incomplete regardless of what the enumeration returned. A client MUST NOT publish to its own coordinates while its own load is in progress; a self-inflicted replacement is the same defect with the client on both ends of it. + +1. Every query MUST carry the same explicit `limit` `n`, `n` MUST be at least `L`, and no query MUST constrain tags. `C` and the floor are only meaningful across queries that differ solely in their time bounds. `n` SHOULD be substantially larger than `L`: `n` bounds how many events a single band can retrieve, so a small `n` costs round trips without making any verdict safer. +2. From each delivered event, collect the read-state coordinates — those whose `d` tag has the form `read-state:` — deduplicating by `d` tag value and retaining, of the entries sharing a `d` tag value, the one with the greatest `created_at`, and on equal `created_at` the one with the lexicographically lowest event id. That is the addressable ordering NIP-01 defines, and both halves of it are load-bearing here: a replacement published in the same second as the version it replaces is legal and is the one the relay retains, so a retention rule that only compares `created_at` may keep the superseded version even when the fence delivered its successor perfectly. Ignore the other events, but count them: they are part of what the cap returned. Events the fence delivers are collected the same way, but MUST NOT contribute to `T` or to `C`: they are not a query result, and an event arriving below the cursor would otherwise move it down and skip the band between. Collection is what recovers a moved coordinate; the cursor descends on query results alone. +3. Let `T` be the lowest `created_at` across **all** events delivered by the queries in this load, not only the read-state ones. The cursor therefore advances on every non-empty page, including one that yielded no coordinate. +4. Before advancing past `T`, the client MUST query the pinned window `{"since": T, "until": T}` and merge the result. A cap can cut mid-second, leaving events at `T` that a continuation at `"until": T - 1` would skip forever; pinning both bounds to one second removes every event outside that second from contention for the cap. +5. Second `T` is exhausted only if that pinned query delivered fewer than `max(C, L)` events. If it delivered `max(C, L)` or more, the cap may have bound inside the second, no finer cursor exists on the standard filter surface, and the load is **potentially incomplete** and MUST be reported as such. This verdict is terminal for the load: the client MUST NOT continue to step 6, and no later observation upgrades it. A continuation past an undischarged second can deliver nothing simply because that second was the oldest, so an empty continuation is not evidence that the second above it was exhausted. +6. Otherwise continue with `"until": T - 1`. Because a bare `until` is inclusive, decrementing guarantees each continuation covers a strictly older band, so the loop makes progress regardless of how the relay caps. +7. The load is **complete** when a continuation delivers no events at all, every preceding second having been discharged by step 5, the fence having been established before the first query and held unbroken since, and every fence delivery received up to that continuation's end-of-stored-events having been collected. Because the filter constrains nothing the relay applies after its cap, an empty delivery is an empty result — a cap that returns nothing is not a cap. +8. A load that failed, or whose fence lapsed, on any relay the client publishes to is potentially incomplete (see Read-Before-Write). + +This layer places five requirements on every relay a client performs a full-state load against. They are stated normatively, not as background assumptions, because a *complete* verdict rests on them and none of them is verifiable from the responses a client receives: + +- **Newest-first prefix delivery.** A capped result MUST consist of the newest events by `created_at` for the filter, ties broken by lowest id — the delivery NIP-01 already specifies for `limit`. A relay that caps by returning some other subset can omit an event lying *above* the cursor the client derives from that same delivery, so the omitted event is never queried at all. Repeating a query cannot recover it, because the same filter with the same bounds is the same request. +- **Non-decreasing effective cap within a load.** A relay MUST NOT reduce, within a single load, the number of events it will deliver for queries that differ solely in their time bounds. A cap that shrinks between the query establishing `C` and a later pinned window makes that window's short delivery indistinguishable from exhaustion, which converts a truncated second into a discharged one. +- **The floor `L`.** A relay MUST deliver at least `L` events for a query whose matching set holds at least `L` and whose requested `limit` is at least `L`. `L = 2`, fixed by this NIP; a client MUST NOT derive it from relay-advertised discovery, because an advertised maximum is not necessarily the limit a relay enforces. +- **Push delivery on an open subscription.** A relay MUST deliver every event it accepts that matches an open subscription's filter to that subscription. The mutation fence in step 2 is exactly this delivery; a relay that accepts a replacement without pushing it gives the client no way to observe a coordinate that moved above the cursor. +- **The delivery barrier.** Before a relay sends end-of-stored-events for a query, every event it accepted before that query read its stored events, and which matches an open subscription on the same connection, MUST already have been delivered to that subscription. Push delivery alone promises only that the replacement arrives eventually; the barrier is what places it before the verdict that depends on it. Without it, a relay whose accept path and query path proceed independently can answer a query from storage the replacement has already changed while the corresponding push is still pending, and the client discharges the load in the interval between the two. + +A client cannot distinguish a relay that violates any of these from one that simply had fewer events to return, so these are conformance preconditions of this layer rather than properties a load establishes. A client MUST NOT perform a full-state load against a relay it knows, or has evidence, to violate them, and MUST treat any load against such a relay as potentially incomplete. Conditioning *complete* on positive proof of these properties instead would be equivalent to never issuing it — no such proof exists on the standard filter surface — which would withdraw the override layer from every client rather than from the non-conforming relays. + +The comparison in step 5 fails safe: a pinned window is reported potentially incomplete unless the relay has already shown it will deliver at least that many at once, so an inconclusive result is never mistaken for an exhaustive one. A plateau of more events at a single `created_at` than the relay will deliver for a window pinned to that second is therefore unenumerable, because this NIP defines no finer cursor, and it resolves to *cannot prove complete* rather than to a false *complete*. The comparison is a lower bound on the cap rather than the cap itself, so it is also conservative in the other direction: a load whose oldest second holds as many events as the largest delivery observed so far resolves to *cannot prove complete* even where the relay would have delivered more. Where more than one coordinate exists this is transient, because any later publish moves that coordinate to a different second and separates the two. + +The floor is the narrowest of the five requirements and the one that makes the ordinary case reachable at all. A coordinate at a replaceable kind contributes exactly one event no matter how many times it is republished, because a republish replaces the previous version rather than appending to it; a client's event count at this kind therefore does not grow over time, and a single-installation client publishing under one coordinate has one event at one second permanently. Without a floor, `C` for such a client is one, its pinned window delivers one, and step 5 can never be discharged — mark-unread would be permanently unavailable to the most common conforming deployment, and no amount of waiting or republishing would change the observation. `L = 2` discharges it: the pinned window delivers one event, `max(C, L)` is two, `1 < 2`, the second is exhausted, and the continuation below it is empty. That same replacement behaviour is what the fence exists for: the one event a coordinate contributes can move, and it moves by being replaced. + +A **potentially incomplete** load MUST NOT be the basis for any of the following, each of which either destroys override state or asserts authority over it: + +- canonical compaction of an override register (see Mandatory Canonical Publication), +- publishing a canonicalized override blob, +- deleting or abandoning any coordinate (see Orphaned Blob Deletion), +- reporting an explicit mark-read as successful (see Actions). + +Until a complete load succeeds, the client MUST evaluate unread state from its own locally persisted state and MUST report override actions as failed rather than acting on a partial view. The honest terminal states are *complete* and *cannot prove complete*; a client MUST NOT treat the second as the first. + +The number of coordinates a full-state load must retrieve is bounded by the number of installations that have ever used the override layer, plus their not-yet-deleted rotation predecessors. It grows with the user's device history, not with elapsed time, and — because a coordinate carrying `ov_*` entries may not be deleted until it has been carried forward (see Client-ID Rotation) — it does not shrink on its own. Clients SHOULD carry forward and delete rotation predecessors promptly so the count stays near one coordinate per live installation. ### Merge Rule @@ -311,6 +388,8 @@ effective[context] = max(timestamp) across all blobs This is a grow-only max-register state-based CvRDT with an associative, commutative, idempotent join. Clients MUST NOT lower a read timestamp — only advance it. +The manual-unread override layer (see Manual-Unread Override Layer below) adds per-context set/clear counters merged by the same componentwise `max()` rule. The frontier merge rule is unchanged. + ### Writing Clients MAY publish read state automatically when read-position sync is part of @@ -320,28 +399,32 @@ to other users MUST require explicit user consent. Clients SHOULD publish read state blobs to the same relays they use for general event storage. Clients that implement NIP-65 (relay list metadata) SHOULD publish to their write relays and fetch from their read relays. -Each client instance maintains its own blob (one `kind:30078` event per ``). Writing replaces the previous blob via parameterized replaceable event semantics ([NIP-33](33.md)). +Each client instance maintains its own primary blob (one `kind:30078` event at its primary coordinate), plus one event per additional frontier-only coordinate if it uses any. Writing replaces the previous blob at each coordinate via parameterized replaceable event semantics ([NIP-33](33.md)). -Clients MUST only update the blob whose decrypted `client_id` matches their own `client_id`. Clients MUST NOT overwrite another instance's blob. +Clients MUST only update blobs whose decrypted `client_id` matches their own `client_id`. Clients MUST NOT overwrite another instance's blob. -If the client discovers multiple blobs with its own `client_id` during a fetch, it MUST select the one with the highest `created_at` as its active blob and SHOULD delete the others. +If the client discovers same-`client_id` blobs at coordinates that are neither its primary nor one of its known additional coordinates (e.g., rotation orphans or backup/restore duplicates), it MUST merge them into its own state and MUST NOT delete them until their override state has been carried forward (see Orphaned Blob Deletion). It MUST NOT publish to them: its own writes go to its primary and its known additional coordinates only. #### Read-Before-Write Before publishing, a client MUST: -1. Fetch its own current blob from each relay it intends to publish to, and merge all fetched versions. +1. Fetch its own current blob(s) from each relay it intends to publish to, and merge all fetched versions. -The client fetches its own blob using its known `d` tag value: +A client fetches its own coordinates using their known `d` tag values — its primary, plus its additional frontier-only coordinates if any — and unions them componentwise: ```json -{"kinds": [30078], "authors": [""], "#d": ["read-state:"]} +{"kinds": [30078], "authors": [""], "#d": ["read-state:", "read-state:", ...]} ``` -2. Decrypt and merge the fetched blob with local state using `max()` per context. +A read-before-write fetch of the client's own coordinates is not a full-state load: it cannot discover rotation orphans or duplicates. Before canonicalizing override state or publishing a canonicalized override blob, the client MUST have a complete full-state load (see Full-State Load). + +2. Decrypt and merge the fetched blob(s) with local state using `max()` per context. 3. Publish the merged result. -If a relay is unreachable during the fetch step, the client SHOULD proceed with the data available from reachable relays. The merge rule ensures that data from the unreachable relay will be incorporated on the next successful fetch, provided the relay retains the event. Permanent relay loss or event expiry may result in state loss — this is an accepted property of the best-effort model (see Non-Goals). +If a relay is unreachable during the fetch step, the client SHOULD proceed with the data available from reachable relays. The merge rule ensures that data from the unreachable relay will be incorporated on the next successful fetch, provided the relay retains the event. Permanent relay loss or event expiry may result in loss of frontier state — this is an accepted property of the best-effort model (see Non-Goals). + +That accepted loss does not extend to override state. A client MUST NOT treat a fetch that failed on any relay it publishes to as a complete view of its own override state, and MUST NOT canonicalize, publish canonicalized override state, or delete or abandon any of its own coordinates on the basis of such a partial fetch (see Full-State Load). Clients implementing the override layer SHOULD publish override state to more than one relay so that the loss of a single relay does not erase a tombstone floor. This read-before-write requirement also applies to re-publishes triggered by incoming blobs from other instances (see Live Subscription and Convergence). @@ -358,12 +441,14 @@ Clients SHOULD subscribe to `kind:30078` events for their own pubkey with `#t: [ When a blob from another client instance arrives (i.e., its decrypted `client_id` does not match the client's own `client_id`): 1. Merge it into local state using `max()` per context. -2. If any context timestamp in the incoming blob is greater than the corresponding timestamp in the client's last-published blob (or the context is absent from the last-published blob), perform a read-before-write and re-publish the client's own blob after a debounce delay. -3. Clients MUST suppress the re-publish if the merged result is identical to the client's last-published blob. A client that has never published treats its last-published blob as empty. +2. Canonicalize the merged override state against the client's own effective frontier (applying the tombstone floor and live/dead/virgin rules from Mandatory Canonical Publication). If any context entry in the canonical merged result differs from the corresponding entry in the client's last-published canonical blob (or the context is absent from the last-published blob), perform a read-before-write and re-publish the client's own blob after a debounce delay. +3. Clients MUST suppress the re-publish if the canonical merged result is identical to the canonical form of the client's last-published blob. Comparing canonical-to-canonical prevents a retained live peer blob (which the client has already tombstoned) from triggering an identical write on every replay. A client that has never published treats its last-published blob as empty. 4. Clients SHOULD limit re-publishes triggered by incoming blobs to at most one per debounce window, regardless of how many blobs arrive during that window. This drives convergence without a coordination round-trip, assuming eventual relay reachability and event retention. +A live subscription is not a full-state load. A relay MAY return a capped set of stored events before end-of-stored-events on this filter, so a client implementing the override layer MUST NOT treat what the subscription delivers as a complete view of its coordinates (see Full-State Load). Merging an incoming blob into local state (step 1) is always safe, because merge is componentwise `max()`; the canonicalize-and-re-publish in steps 2–3 is a canonical publication and therefore requires a complete full-state load. A client that does not have one MUST defer the re-publish rather than publish a canonical blob derived from a partial view. A subscription is nonetheless a required *component* of a full-state load, serving as its mutation fence, and the fence MUST use the tag-free filter rather than the `#t`-narrowed one above — a replacement it fails to deliver is a replacement the descending enumeration cannot recover. + #### Clock Skew When publishing, if the client's local clock produces a `created_at` value less than or equal to the maximum `created_at` seen across all fetched blobs for the same `d` tag, the client MUST use `max_fetched_created_at + 1` instead. @@ -372,17 +457,158 @@ When publishing, if the client's local clock produces a `created_at` value less Clients SHOULD debounce writes to avoid excessive relay traffic (e.g., flush 5–10 seconds after the last local read-state change, or on app close/background transition). Clients MUST NOT write on every individual read action. -The blob SHOULD contain only contexts the client has explicitly interacted with. Clients SHOULD prune aggressively, prioritizing recently-active contexts, and MAY drop entries older than the time horizon before writing. Clients MUST ensure the published event does not exceed relay event size limits (typically 64 KB content). +The blob SHOULD contain only contexts the client has explicitly interacted with. Clients SHOULD prune aggressively, prioritizing recently-active contexts, and MAY drop frontier entries older than the time horizon before writing. Clients MUST NOT drop `ov_*` override entries (including tombstone floors) based on age or budget pressure; see Override State Durability in the Manual-Unread Override Layer section. Clients MUST ensure the published event does not exceed relay event size limits (typically 64 KB content). #### Client-ID Rotation -Clients MAY rotate their `client_id` by generating a new one, generating a new random ``, and publishing a new blob. The old blob becomes orphaned and ages out of the time horizon naturally. Rotation adds one extra blob temporarily. Clients SHOULD keep their `client_id` stable for as long as possible to minimize blob proliferation. +Clients MAY rotate their `client_id` by generating a new one, generating a new random `` for the primary coordinate, and publishing a new blob. Rotation adds one extra blob temporarily. Clients SHOULD keep their `client_id` stable for as long as possible to minimize blob proliferation. + +Rotation is the only event that changes a client's override-bearing coordinate, and it carries the layer's single durability obligation: + +**Carry-forward rule.** Before deleting or abandoning its previous primary, a rotating client MUST publish the componentwise `max()` of every override register the old primary holds — every tombstone ceiling included — under its new primary, and MUST confirm acceptance **on every relay from which the old primary will be deleted or allowed to lapse**. If any such relay rejects the publish or is unreachable, the client MUST retain the old primary on that relay and MUST NOT delete it there. Acceptance on one relay does not authorize deletion on another: a relay that never received the replacement would otherwise be left with no local carrier of the floor. The old primary MUST NOT be left to age out while it is the only carrier of an override floor on any relay. -If a device backup or clone results in two installations sharing the same `client_id` and `slot-id`, both will write to the same blob. This is operationally equivalent to a single client and does not corrupt state, but the two installations will overwrite each other's context entries. Clients that detect this condition (e.g., by observing unexpected context changes in their own blob) SHOULD generate a new `client_id` and `slot-id`. +Additional frontier-only coordinates carry no override state, so rotation may abandon or delete them freely. + +If a device backup or clone results in two installations sharing the same `client_id` and primary ``, both will write to the same coordinate. This is operationally equivalent to a single client and does not corrupt state, but the two installations will overwrite each other's context entries. Clients that detect this condition (e.g., by observing unexpected context changes in their own blob) SHOULD generate a new `client_id` and a fresh primary ``, again carrying override state forward per the carry-forward rule. #### Orphaned Blob Deletion -Clients MAY delete blobs from decommissioned client instances by publishing a `kind:5` deletion event per [NIP-09](09.md) targeting the orphaned event's `a` tag coordinate (`30078::`). This is optional — orphaned blobs are harmless and age out naturally. +Clients MAY delete blobs from decommissioned client instances by publishing a `kind:5` deletion event per [NIP-09](09.md) targeting the orphaned event's `a` tag coordinate (`30078::`). For blobs carrying no `ov_*` entries — including a client's own additional frontier-only coordinates — this is optional and unconditional: such blobs are harmless and age out naturally. + +A blob carrying `ov_*` entries MUST NOT be deleted or abandoned until its override state has been carried forward per the carry-forward rule in Client-ID Rotation. This applies to the client's own previous primary and to same-`client_id` orphans discovered from a prior rotation or a backup/restore. A client's record of its own coordinates MAY be stale — for example restored from a backup taken before a rotation — so an unknown same-`client_id` coordinate MUST be treated as a live carrier of override state, not as a deletable duplicate, until it has been merged and carried forward. + +### Manual-Unread Override Layer + +This section defines a manual mark-as-unread mechanism as a CRDT override layer within the existing `contexts` map. It does not change the frontier merge rule, event structure, or encryption scheme. Fetching follows the override-specific full-state procedure (see Full-State Load) rather than the horizon-bounded fetch used by clients that do not implement this section. Clients that do not implement this section remain fully interoperable (see Backwards Compatibility). + +#### Wire Encoding + +For each manually-unread context ``, a client publishes up to three sibling keys alongside the existing frontier entry in the `contexts` map: + +| Key | Type | Description | +|-----|------|-------------| +| `ov_s:` | uint32 | Set counter S — incremented on each mark-unread | +| `ov_c:` | uint32 | Clear counter C — incremented on each mark-read | +| `ov_b:` | uint32 | Baseline B — the effective frontier value at the time of the most recent mark-unread | + +Values MUST be integers in the range 0–4294967295 (same validation range as context timestamps). The `` suffix is the raw context ID without any escaping (escaping applies only to the frontier wire key; see Reserved Namespace). + +#### Merge Rule (Override Registers) + +Override counters are merged by componentwise `max()`, identical to the frontier merge rule: + +``` +merged_S[ctx] = max(S) across all blobs +merged_C[ctx] = max(C) across all blobs +merged_B[ctx] = max(B) across all blobs +``` + +No new wire-level merge logic is required. The same `mergeReadStateEvents` path that joins frontier timestamps joins the counter entries as integer max. + +#### Liveness Predicate + +A context `ctx` has an active manual-unread override if and only if ALL of the following hold, evaluated against the merged register `(S, C, B)` and the merged effective frontier `F`: + +1. `S > 0` — at least one mark-unread action has been recorded. +2. `F <= B` — the effective frontier has not advanced past the baseline captured at mark-unread time. (A natural frontier advance strictly past `B` dominates a stale set, clearing the override without any explicit clear action.) +3. `S > C` — set counter exceeds clear counter. (`S == C` is treated as inactive: clear wins on ties — see Tie Policy.) + +Formally (clear-wins is the only conforming tie policy — see Tie Policy): + +``` +override_active(S, C, B, F) = + S > 0 + AND F <= B + AND S > C +``` + +The **unread verdict** for a context is: + +``` +unread(ctx) = (latest_message_ts > F) OR override_active(S, C, B, F) +``` + +where `latest_message_ts` is the `created_at` of the newest message in the context. + +#### Actions + +Every action below requires a complete full-state load (see Full-State Load); on a potentially incomplete load the client MUST report the action as failed rather than act on a partial view of its own override state. + +**Mark-unread:** increment S to `max(S, C) + 1`; set B to the current effective frontier value for the context. C is unchanged. If `max(S, C) == 4294967295` (uint32 maximum), the client MUST refuse the mark-unread action and leave the register unchanged; wrapping or resetting to zero is prohibited. + +**Mark-read (explicit):** advance the frontier to cover the context as normal; increment C to `max(S, C) + 1`. S and B are unchanged. If `max(S, C) == 4294967295`, no representable counter increment exists; wrapping or resetting to zero is prohibited. The client MUST then complete the action only if the resulting state satisfies `override_active == false` — i.e. the frontier advance alone deactivates the override, or the override was already inactive. Otherwise the counters MUST be left unchanged and the client MUST report the mark-read as failed; the monotone frontier advance itself is still permitted, but a client MUST NOT report an explicit mark-read as successful while `override_active` remains true. + +**Natural read (frontier advance):** advance the frontier past B. No counter update is needed — the liveness predicate's `F <= B` condition automatically deactivates the override when the frontier dominates the baseline. + +#### Tombstone Floor + +A register where `S > 0` or `C > 0` (ever-active) that evaluates as inactive MUST be compacted to the tombstone floor before publication: + +``` +tombstone = RegB(S=0, C=max(S, C), B=0) +``` + +This preserves the counter ceiling as a reuse-blocking floor. A register where `S == 0` and `C == 0` (virgin, never activated) MUST be omitted from the wire entirely (0 keys). + +#### Mandatory Canonical Publication + +Publishers MUST canonicalize every override against their own effective frontier at serialization time before writing to the wire: + +- **Live override** (`override_active` is true): publish all three keys (`ov_s:`, `ov_c:`, `ov_b:`) with their current values unchanged. +- **Dead override** (`override_active` is false, `S > 0` or `C > 0`): publish only the tombstone floor — a single `ov_c:` key with value `max(S, C)`. +- **Virgin register** (`S == 0` and `C == 0`): omit all three keys from the wire. + +This is a protocol requirement, not an optimization. A client that publishes raw (non-canonical) dead registers can cause two independently-dead registers from different devices to produce a live join on merge. See `docs/formal/nip-rs-unread/` for the exhaustive proof and mutation harness. + +#### Override Group Co-Location Rule + +A context's frontier entry and ALL of its `ov_*` sibling entries MUST travel in the same event, and that event MUST be the primary coordinate. Because all `ov_*` entries live in the primary (see `d` Tag), an override-bearing context has exactly one legal destination for its whole group: a client that splits frontier entries into additional coordinates MUST NOT move the frontier entry of an override-bearing context out of the primary, and MUST NOT place `ov_*` entries anywhere else. Only frontier-only groups — contexts with no `ov_*` entries — may be distributed across additional coordinates. + +Implementations that split blobs across coordinates MUST group context entries per logical context — not per individual key — and assign the entire group atomically to one coordinate. Round-robin or other assignment strategies MUST operate on groups, not on individual entries. + +**Unescape-before-group rule (corollary):** when grouping, a frontier wire key MUST be unescaped to its raw logical context ID (stripping one leading `esc:` if present) before being used as the group identity. Without this step, a frontier key `esc:ov_s:evil` and its `ov_*` siblings (keyed by the raw suffix `ov_s:evil`) resolve to different groups and the register splits across coordinates, reproducing the partial-reconstruction poison across publication cycles. + +**Rationale:** a receiver holding only a partial group (e.g., `ov_s:ctx` without `ov_b:ctx`) reconstructs a register with incorrect baseline and may canonically publish a false tombstone. With atomic grouping, a compliant publisher's output never permits partial reconstruction. + +#### Tie Policy + +**Clients MUST use clear-wins.** When `S == C` and `S > 0`, the override MUST be treated as inactive, and the register MUST be compacted to the tombstone floor on publication (see Tombstone Floor). + +Clear-wins is normative rather than a local implementation choice because the tie verdict is not encoded on the wire. Two conforming clients holding the same merged register `(S, C, B, F) = (1, 1, 10, 10)` would otherwise disagree permanently: a clear-wins client reports read and publishes the single-key tombstone floor, a set-wins client reports unread and publishes all three keys. Further deliveries converge the counters but can never converge either the verdict or the canonical wire form, which defeats cross-device synchronization. Supporting a selectable tie policy would require encoding the policy in the blob plus a separate interoperability design; neither is in scope here. + +Clear-wins also matches the product semantics this layer is designed for: a false negative (a missed badge) is recoverable by re-marking unread, while a false positive (a badge that will not clear) is more disruptive. See `docs/formal/nip-rs-unread/NOTE.md` for the policy comparison — both policies satisfy the merge-correctness invariants in isolation, so this is an interoperability requirement, not a merge-safety one. + +#### Override State Durability + +`ov_*` override entries — especially tombstone floors (`ov_c:` keys) — carry a reuse-blocking counter ceiling that prevents stale override components from resurrecting a dead register. Specifically, if a tombstone floor `(S=0, C=k, B=0)` is dropped and a stale snapshot `(S=k, C=0, B=b)` is later replayed, the merged result `(S=k, C=0, B=b)` would evaluate as live — a resurrection. + +Because legacy clients can carry and republish old `ov_*` keys indefinitely (they pass through `sanitizeContexts` as unknown opaque entries), there is no finite time after which all stale override components are guaranteed absent. Therefore: + +**Clients MUST NOT drop `ov_*` override entries (including tombstone floors) based on age pruning or budget eviction.** This exemption applies permanently. Age-based pruning applies to frontier entries only. Eviction strategies that respect byte/key budgets MUST apply to frontier and `msg:`/`thread:` entries first and MUST NOT touch `ov_*` entries. + +**Durability is a property of retrievable logical state, not of keys within one blob.** An override register survives only if a client that loads its full state can still reach every component. Therefore, in addition to the per-entry rule above: + +- Full-state loads by clients implementing this layer MUST NOT be restricted by a finite event-level `since` window, and MUST establish completeness rather than assume it; the containing event must remain reachable, not merely retain its keys (see Full-State Load). +- No coordinate carrying `ov_*` entries may be deleted or abandoned until the componentwise `max()` of every override register it holds — especially every tombstone ceiling — has been republished under the client's current primary coordinate and accepted on every relay from which the old coordinate will be deleted or allowed to lapse (see Client-ID Rotation, Orphaned Blob Deletion). + +**There is no safe finite GC horizon for override state.** Any protocol that proposes to delete tombstone floors after a bounded period requires a separately proved guarantee that no stale override component can re-enter the merge — this amendment does not provide such a guarantee. + +#### Bounds and Budget + +- **Key growth:** a live override adds 3 entries per context; a tombstoned override adds 1 entry per context. At 100 overridden channel contexts: ~300 live entries or ~100 tombstone entries. +- **Byte cost (small-counter example, common case):** channel UUID context (36 chars), counters S=1/C=0/B=10 — live override ~138 bytes; tombstone ~45 bytes. **Byte cost at uint32 maximum** (S=4294967295, worst case): live override ~164 bytes; tombstone ~54 bytes. +- **Hard ceiling on ever-overridden contexts.** Because all `ov_*` entries live in one coordinate (see `d` Tag) and tombstones can never be pruned (see Override State Durability — there is no safe finite GC horizon), the primary blob's plaintext budget is a hard ceiling on the number of contexts a single installation can ever have manually marked unread. Against a 32 KiB plaintext budget: roughly **600** tombstoned contexts at the worst-case ~54 bytes, ~730 at the common ~45 bytes, or ~199 simultaneously live overrides at ~164 bytes — and that is before frontier entries get any room at all. +- **Terminal behaviour at the ceiling.** When the primary blob cannot accommodate a new override group after all prunable frontier entries have been evicted, the client MUST refuse the mark-unread action and report it as failed. It MUST NOT split override state across coordinates and MUST NOT drop tombstone floors to make room. Likewise, a client whose merged override state — including tombstones merged in from peer installations — no longer fits in its primary blob MUST leave its last-published primary in place, MUST NOT publish a primary that omits merged `ov_*` entries, and MUST report override actions as failed; publishing a truncated override set is budget-driven override loss under another name. No floor is lost in this state, because the installations that originated those floors still carry them; the constrained installation simply stops acting as a replica until it has room. This is the same policy shape as counter exhaustion (see Actions): visible failure, never silent degradation. +- **10,000-key limit:** override entries count toward the existing per-blob validation limit. Tombstones accumulate permanently with every distinct ever-overridden context; they cannot be pruned. Clients SHOULD compact dead overrides aggressively and MAY enforce an active-live-override cap. Note that a cap on live overrides does not bound the total `ov_*` entry count over an unbounded context lifetime — tombstones from all historical overrides remain. The 32 KiB and 10,000-entry limits are expressed per blob at write time; a client that has overridden many distinct contexts over its lifetime must account for all accumulated tombstones when evaluating budget headroom. +- **256-byte key limit:** override keys (`ov_s:`, `ov_c:`, `ov_b:` + context ID) count toward the per-entry 256-byte validation limit. Context IDs up to 251 bytes are safe. Buzz's own context ID shapes (UUID 36 bytes, `msg:hex64` 68 bytes, `thread:hex64` 71 bytes) are well within this limit. + +#### Verification Artifact + +The design was verified by bounded exhaustive model checking prior to this amendment. See `docs/formal/nip-rs-unread/` for the full model (`model.py`, `exhaustive.py`), 9-mutant harness (`mutation.py`), and design notes (`NOTE.md`). + +The harness verifies the three load-bearing safety requirements — tombstone floor, mandatory canonical publication, and atomic per-context grouping with unescape-before-group — are necessary: each mutant that drops one of these rules produces a detectable witness of permanent false-clear or resurrection. M3 validates that the clear-wins tie policy produces the intended product-semantics behavior; M5 and M6 witness value-range and convergence failures respectively. Clear-wins is normative for interoperability (see Tie Policy), not because set-wins violates merge safety — the model confirms both tie policies satisfy the merge-correctness invariants when applied uniformly. + +**Scope of formal verification:** the bounded model covers the CRDT register algebra, merge/compaction rules, per-context grouping atomicity, and escape/unescape bijection. The model is a broader predecessor of this NIP: its `split_blob_into_slots` permits override groups in any slot, whereas this NIP confines them to one primary coordinate, so the verified atomicity property holds for every arrangement this NIP permits but the converse does not follow. The model does **not** verify the single-primary rule, the full-state-load completeness procedure, the relay conformance requirements or the mutation fence it depends on, or the carry-forward rule; those are normative here and argued, not proved. The model also does NOT cover malformed-group wire validation (the accepted-shape rules in Content Validation). That rule is sound by the partial-group argument (rejecting a partial group leaves a virgin register — a merge no-op — which is strictly safer than zero-filling missing components), but its correctness under parser-level implementation is outside the model's verified scope. Implementation-level tests MUST cover the accepted wire shapes and rejection behavior. ## Example @@ -504,9 +730,9 @@ The conversation key is `nip44_conversation_key(private_key, public_key)` — EC ### Conflict Detection Vector -Device A has `slot-id` = `aaa111` and `client_id` = `client-A`. It fetches its own `d` tag coordinate `read-state:aaa111` and decrypts the blob. The decrypted `client_id` is `client-B` (not `client-A`). This is a slot-id conflict — another device has claimed this coordinate. +Device A has `slot-id` = `aaa111aaa111aaa111aaa111aaa111aa` and `client_id` = `client-A`. It fetches its own `d` tag coordinate `read-state:aaa111aaa111aaa111aaa111aaa111aa` and decrypts the blob. The decrypted `client_id` is `client-B` (not `client-A`). This is a slot-id conflict — another device has claimed this coordinate. -Device A MUST NOT publish to `read-state:aaa111`. Device A MUST generate a new random `slot-id` (e.g., `ccc333`) and publish its blob under `read-state:ccc333`. +Device A MUST NOT publish to `read-state:aaa111aaa111aaa111aaa111aaa111aa`. Device A MUST generate a new random `slot-id` (e.g., `ccc333ccc333ccc333ccc333ccc333cc`) and publish its blob under `read-state:ccc333ccc333ccc333ccc333ccc333cc`. ### Clock Skew Vector @@ -541,7 +767,7 @@ Ciphertext length reveals the approximate number of tracked contexts and may cor Because slot IDs are random and independent of `client_id` values, relay operators cannot directly link blobs to specific devices or client implementations. Timing correlation and write patterns may still allow probabilistic linkage. -Because the merge rule is monotonic, replaying an old event to a relay is harmless — it cannot lower a read timestamp. However, replaying many old events simultaneously could trigger convergence re-publishes from active clients. The debounce window (see Debounce and Pruning) limits this to at most one re-publish per window. +Because the frontier merge rule is monotonic, replaying an old frontier event to a relay is harmless — it cannot lower a read timestamp. The override layer's counter merge is also monotonic (componentwise max), so replaying an old override event cannot lower a counter; however, a stale override component replayed after a tombstone floor was published could suppress a fresh set for one reconciliation cycle (see Override State Durability). The debounce window (see Debounce and Pruning) limits convergence re-publishes to at most one per window. Clients supporting multiple Nostr identities SHOULD use distinct `client_id` values and distinct slot IDs per identity. Reusing identifiers across pubkeys allows relay operators to link those identities. @@ -557,10 +783,13 @@ that expose read activity to other users MUST require explicit user consent. ## Backwards Compatibility -This NIP introduces no changes to existing event kinds or relay behavior. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected. +This NIP introduces no changes to existing event kinds and adds no new kind, wire message, or relay-stored read-state logic. It uses only standard NIP-01 event storage, NIP-33 addressable event semantics, NIP-44 encryption, and NIP-78 application data conventions. Clients that do not implement this NIP are unaffected, as are clients that implement everything but the manual-unread override layer. + +The override layer is the exception, and it is a relay-compatibility one rather than a client one. Its full-state load carries the completeness guarantee only against a relay that satisfies the ordering, capacity, floor, push, and barrier requirements enumerated in Full-State Load. Against a relay known or evidenced not to conform, every load resolves to *cannot prove complete* and the actions that depend on a complete load report as failed; against an undetectably nonconforming relay, a load may still return *complete*, and the completeness guarantee does not apply to that verdict. In either case the layer still runs and still merges, and frontier sync is unaffected. ## References +- [NIP-01](01.md) — Basic Protocol Flow Description (defines filter `limit`, `since`, and `until`) - [NIP-09](09.md) — Event Deletion Request - [NIP-33](33.md) — Parameterized Replaceable Events - [NIP-44](44.md) — Versioned Encryption From bb34bc4d98fe4dabe847046103ac5e2859917ac5 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 11:38:42 -0600 Subject: [PATCH 43/87] Revert "chore(release): release Buzz Desktop version 0.5.3" (#3960) Reverts block/buzz#3944 --- .release/desktop-candidate.json | 8 ---- CHANGELOG.md | 63 ------------------------------- desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 4 insertions(+), 75 deletions(-) delete mode 100644 .release/desktop-candidate.json diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json deleted file mode 100644 index 150f8023e7..0000000000 --- a/.release/desktop-candidate.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "schema": 1, - "version": "0.5.3", - "base_sha": "052174a148f9f6bcbb2b5a1d20ce0317645e49f8", - "previous_tag": "v0.5.2", - "tag": "desktop-v0.5.3", - "commit_count": 53 -} diff --git a/CHANGELOG.md b/CHANGELOG.md index 974f68c683..d83087fc26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,68 +1,5 @@ # Changelog -## v0.5.3 - -### Desktop and shared changes - -- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) -- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) -- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) -- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) -- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) -- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) -- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) -- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) -- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) -- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) -- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) -- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) -- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) -- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) -- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) -- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) -- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) -- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) -- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) -- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) -- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) -- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) -- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) -- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) -- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) -- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) -- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) -- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) -- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) -- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) -- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) -- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) -- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) -- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) -- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) -- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) -- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) -- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) -- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) -- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) -- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) -- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) - -### Other repository changes - -- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) -- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) -- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) -- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) -- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) -- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) -- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) -- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) -- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) -- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) -- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) - -[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) - ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/desktop/package.json b/desktop/package.json index e8145f5468..2226a0cb12 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.3", + "version": "0.5.2", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 00d3fba3b5..254b7070ac 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.2" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b80684f955..39aaf0dead 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.3" +version = "0.5.2" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 1ff8bd20ef..2eba7815b2 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.3", + "version": "0.5.2", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From db7e84d4f815127236b9cb080c5d374f48eaac09 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 12:03:51 -0600 Subject: [PATCH 44/87] fix(release): make desktop tagging squash-safe (#3965) ## Summary - validate desktop release candidates before merge and keep the repository squash-only - tag the squash commit only after proving frozen-base parent and complete-tree identity with the validated PR head - accept either an exact-head approval or the durable Default-ruleset bypass record as release authorization - remove the unusable App-backed preparation workflow; retain `just release-desktop` ## Ruleset follow-up After this PR merges, update Default ruleset `13596885` to: - enable strict required status checks - dismiss stale reviews on push and require approval after the last push - require the integration-bound `Desktop Release Candidate` check The next desktop release should be cut only after that settings update. ## Verification At commit `d8c254db427eedbcffac1a6e078e90d1d0f5e151` with a clean worktree: - `scripts/test-release-ref-contract.sh` - `scripts/test-desktop-release-candidate.sh` - `bash -n scripts/verify-desktop-release-merge.sh scripts/prepare-desktop-release.sh scripts/test-release-ref-contract.sh` - `git diff --check` The bypass test fixture is the captured rule-suite shape from real squash merge PR #2864 / suite `3520068134`. --------- Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 4 +- .../workflows/desktop-release-candidate.yml | 26 +++++++ .github/workflows/prepare-desktop-release.yml | 38 ---------- RELEASING.md | 69 ++++++++----------- scripts/desktop-release-bypass-authorized.jq | 10 +++ .../desktop-release-rule-suite-bypass.json | 1 + scripts/prepare-desktop-release.sh | 2 +- scripts/test-release-ref-contract.sh | 20 +++++- scripts/verify-desktop-release-merge.sh | 57 ++++++++++++--- 9 files changed, 135 insertions(+), 92 deletions(-) create mode 100644 .github/workflows/desktop-release-candidate.yml delete mode 100644 .github/workflows/prepare-desktop-release.yml create mode 100644 scripts/desktop-release-bypass-authorized.jq create mode 100644 scripts/fixtures/desktop-release-rule-suite-bypass.json diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index a69eafb404..f44057268d 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.head.sha }}" + echo "target_sha=${{ github.event.pull_request.merge_commit_sha }}" echo "desktop=true" else echo "target_sha=$GITHUB_SHA" @@ -111,7 +111,7 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - PR_PUSHER: ${{ github.event.pull_request.head.user.login }} + MERGED_BY: ${{ github.event.pull_request.merged_by.login }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | VERSION="${VERSION#desktop-v}" diff --git a/.github/workflows/desktop-release-candidate.yml b/.github/workflows/desktop-release-candidate.yml new file mode 100644 index 0000000000..eddebea685 --- /dev/null +++ b/.github/workflows/desktop-release-candidate.yml @@ -0,0 +1,26 @@ +name: Desktop Release Candidate + +on: + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + validate: + name: Desktop Release Candidate + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + persist-credentials: false + - name: Validate immutable desktop candidate + if: startsWith(github.event.pull_request.head.ref, 'version-bump/') + env: + VERSION: ${{ github.event.pull_request.head.ref }} + run: | + VERSION="${VERSION#version-bump/}" + scripts/desktop_release.py validate --candidate HEAD --version "$VERSION" --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/prepare-desktop-release.yml b/.github/workflows/prepare-desktop-release.yml deleted file mode 100644 index 7cc480b93b..0000000000 --- a/.github/workflows/prepare-desktop-release.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Prepare Desktop Release - -on: - workflow_dispatch: - inputs: - version: - description: Semver to prepare (for example 0.5.1) - required: true - -env: - RELEASE_AUTOMATION_NAME: Carl - RELEASE_AUTOMATION_EMAIL: c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz - -jobs: - prepare: - if: github.repository == 'block/buzz' - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Create short-lived release preparer token - id: preparer - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - with: - client-id: ${{ vars.BUZZ_RELEASE_TAGGER_CLIENT_ID }} - private-key: ${{ secrets.BUZZ_RELEASE_TAGGER_PRIVATE_KEY }} - permission-contents: write - permission-pull-requests: write - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - with: - fetch-depth: 0 - token: ${{ steps.preparer.outputs.token }} - - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 - - name: Prepare immutable candidate and open or update PR - env: - GH_TOKEN: ${{ steps.preparer.outputs.token }} - VERSION: ${{ inputs.version }} - run: scripts/prepare-desktop-release.sh "$VERSION" diff --git a/RELEASING.md b/RELEASING.md index 11f669fc9b..e729f8b50c 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -5,7 +5,7 @@ Mobile uses immutable release-candidate tags cut directly from remote `main`: | Lane | Entry point | Artifact | |------|-------------|----------| -| Desktop | `Prepare Desktop Release` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | +| Desktop | `just release-desktop ` | Packaged desktop app (signed/notarized macOS, unsigned Windows, and Linux) | | Relay | `just release-relay` | `ghcr.io/block/buzz` container image | | Mobile | `scripts/mobile-release.sh candidate X.Y.Z` | Exact `mobile-vX.Y.Z-rc.N` source identity | @@ -16,20 +16,15 @@ remains manual because OSS CI cannot trigger private CI. ## Quick Start -Desktop releases are prepared from the current remote `main` by GitHub Actions: +Prepare desktop releases locally from an up-to-date, clean `main` checkout: ```sh -gh workflow run prepare-desktop-release.yml \ - --repo block/buzz \ - --ref main \ - -f version=0.5.3 +just release-desktop 0.5.3 ``` -The equivalent GitHub UI path is **Actions → Prepare Desktop Release → Run -workflow**, select `main`, enter the version without a `v` prefix, and run it. -The local `just release-desktop ` recipe uses the same candidate script, -but the Actions workflow is the canonical operator path because it runs with the -release App identity and does not depend on an operator checkout. +The recipe generates the immutable candidate and opens or updates its pull +request. Candidate branch creation uses the operator's GitHub permissions; the +release App is intentionally limited to creating protected release tags. ```sh # Relay release @@ -52,21 +47,23 @@ or mobile GitHub Release. ### Desktop -1. Run **Prepare Desktop Release** with an explicit version. Automation fetches - the current `origin/main`, regenerates `version-bump/` as one +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 candidate must receive an approval on its exact current head. Any - regeneration changes that head and therefore requires a fresh approval. -3. Merge with **Create a merge commit**. Squash and rebase are invalid for - desktop release PRs. Repository settings and the `main` ruleset must allow - merge commits for this option to exist. -4. `auto-tag-on-release-pr-merge` verifies the two-parent merge, exact candidate - approval, and every required check, then tags the reviewed candidate—not the - merge commit—as `desktop-v`. + The required **Desktop Release Candidate** check validates the exact head. + Authorization is either an approval on that exact head or a permitted Default + ruleset bypass at merge time. Any regeneration changes the head and requires + the checks—and, for the review path, 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 one of the two authorization paths, then tags the squash + commit as `desktop-v`. 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 @@ -233,10 +230,10 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 - **Write access** to the `block/buzz` GitHub repository - An `origin` remote whose configured URL is the canonical `block/buzz` repository -- `gh` CLI version 2.87.0 or newer, authenticated with permission to dispatch - the candidate workflow -- Repository settings and the `main` ruleset configured to allow **merge - commits**; desktop release PRs cannot be squash- or rebase-merged +- `gh` CLI authenticated with permission to push the candidate branch and open + its pull request +- The Default `main` ruleset configured for squash-only merging, strict required + checks, stale-review dismissal, and the **Desktop Release Candidate** check - Release tag ruleset [`14378754`](https://github.com/block/buzz/rules/14378754) active for `desktop-v*` and `mobile-v*`, with creation, update, deletion, and non-fast-forward protections and `buzz-release-bot` as its sole always-bypass @@ -247,7 +244,7 @@ host's Wayland/GStreamer/graphics stack and requires GLib >= 2.72 | Name | Kind | Purpose | |------|------|---------| - | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to prepare candidates and create tags | + | `BUZZ_RELEASE_TAGGER_CLIENT_ID` | Variable | GitHub App client ID used to create protected release tags | | `BUZZ_RELEASE_TAGGER_PRIVATE_KEY` | Secret | GitHub App private key | | `OSX_CODESIGN_ROLE` | Secret | macOS signing role used by `block/apple-codesign-action` | | `CODESIGN_S3_BUCKET` | Secret | macOS signing exchange bucket | @@ -268,21 +265,13 @@ actor list. ## Troubleshooting -### The release PR does not offer **Create a merge commit** +### The desktop candidate is stale or cannot be squash merged -The immutable desktop flow cannot release until both the repository merge -settings and the `main` ruleset allow merge commits. Do not squash the PR: the -auto-tagger deliberately rejects a one-parent squash commit. Enable merge -commits, then merge the already-approved exact candidate head with **Create a -merge commit**. - -### `Prepare Desktop Release` fails before opening a PR - -Check the workflow run first. Confirm `BUZZ_RELEASE_TAGGER_CLIENT_ID` and -`BUZZ_RELEASE_TAGGER_PRIVATE_KEY` are configured and that the release App can -write contents and pull requests. Rerunning the preparer regenerates the -candidate from the then-current `origin/main`; if its head changes, obtain a new -approval before merging. +Do not update the branch manually and do not weaken the ruleset. Run +`just release-desktop ` again from current `main`; this regenerates the +candidate, reruns CI, and requires a fresh approval when using the review path. +The post-merge verifier refuses to tag a squash whose parent differs from the +recorded candidate base or whose tree differs from the validated PR head. ### Local `just release-desktop` fails with "must be on main branch" Switch to `main` and pull latest before running the release recipe. diff --git a/scripts/desktop-release-bypass-authorized.jq b/scripts/desktop-release-bypass-authorized.jq new file mode 100644 index 0000000000..7dc40530b0 --- /dev/null +++ b/scripts/desktop-release-bypass-authorized.jq @@ -0,0 +1,10 @@ +def expected_default_pull_request_rule($ruleset_id): + [.rule_evaluations[] | select( + .rule_source.type == "ruleset" and + .rule_source.id == $ruleset_id and + .enforcement == "active" and + .rule_type == "pull_request" and + .result == "fail" + )] | length == 1; + +.result == "bypass" and expected_default_pull_request_rule($ruleset_id) diff --git a/scripts/fixtures/desktop-release-rule-suite-bypass.json b/scripts/fixtures/desktop-release-rule-suite-bypass.json new file mode 100644 index 0000000000..f2aa784357 --- /dev/null +++ b/scripts/fixtures/desktop-release-rule-suite-bypass.json @@ -0,0 +1 @@ +{"id":3520068134,"actor_id":15384764,"actor_name":"wpfleger96","before_sha":"6e02e0a9022a1a098c44ee611b4d9784addb10c7","after_sha":"209536ade6c5ebf7fa82671d7ca0b74f599a40cc","ref":"refs/heads/main","repository_id":1174789082,"repository_name":"buzz","pushed_at":"2026-07-31T11:06:21-06:00","result":"bypass","rule_evaluations":[{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"fail","rule_type":"pull_request","details":"1 review requesting changes by reviewers with write access."},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"required_status_checks"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"non_fast_forward"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"deletion"}]} \ No newline at end of file diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index c477a84cda..9a6f4d1cfa 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -71,7 +71,7 @@ cat >"$body" </dev/null || { + echo "real squash-bypass fixture was rejected" >&2 + exit 1 +} +for mutation in \ + '.result = "pass"' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).result = "pass"' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).rule_source.id = 0' \ + '(.rule_evaluations[] | select(.rule_type == "pull_request")).enforcement = "evaluate"' \ + 'del(.rule_evaluations[] | select(.rule_type == "pull_request"))'; do + if jq "$mutation" "$bypass_fixture" | jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" >/dev/null; then + echo "bypass filter accepted invalid fixture mutation: $mutation" >&2 + exit 1 + fi +done review_filter="$repo_root/scripts/review-decision-approved.jq" for fixture in \ '{"reviewDecision":"CHANGES_REQUESTED"}' \ diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh index 17bf2f4dcb..d8ca8015ba 100755 --- a/scripts/verify-desktop-release-merge.sh +++ b/scripts/verify-desktop-release-merge.sh @@ -3,10 +3,14 @@ set -euo pipefail : "${PR_HEAD_SHA:?}" : "${MERGE_SHA:?}" +: "${MERGED_BY:?}" : "${VERSION:?}" : "${PR_NUMBER:?}" : "${GH_TOKEN:?}" +# This ID is the release-authority policy anchor. A bypass of another ruleset +# must never authorize a desktop release. +readonly DEFAULT_RULESET_ID=13596885 required_checks=( "Desktop E2E Integration" "Desktop" @@ -21,6 +25,7 @@ required_checks=( "Relay E2E" "Desktop Build (macOS)" "DCO Check" + "Desktop Release Candidate" ) expected_branch="version-bump/$VERSION" @@ -30,21 +35,53 @@ expected_branch="version-bump/$VERSION" git fetch origin "$MERGE_SHA" "$PR_HEAD_SHA" refs/heads/main:refs/remotes/origin/main --no-tags mapfile -t parents < <(git show -s --format='%P' "$MERGE_SHA" | tr ' ' '\n') -[[ "${#parents[@]}" -eq 2 ]] || { echo "desktop release was not merged with a true merge commit" >&2; exit 1; } -[[ "${parents[1]}" == "$PR_HEAD_SHA" ]] || { echo "merge parent 2 is not the reviewed candidate" >&2; exit 1; } -git merge-base --is-ancestor "$PR_HEAD_SHA" origin/main || { echo "candidate is not reachable from current main" >&2; exit 1; } +[[ "${#parents[@]}" -eq 1 ]] || { echo "desktop release was not squash merged" >&2; exit 1; } +base_sha="$(git show "$PR_HEAD_SHA:.release/desktop-candidate.json" | jq -r .base_sha)" +[[ "${parents[0]}" == "$base_sha" ]] || { echo "squash parent is not the frozen candidate base" >&2; exit 1; } +[[ "$(git show -s --format=%T "$MERGE_SHA")" == "$(git show -s --format=%T "$PR_HEAD_SHA")" ]] || { + echo "squash tree differs from the validated candidate" >&2 + exit 1 +} +git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit is not reachable from current main" >&2; exit 1; } git checkout --detach "$PR_HEAD_SHA" scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -review=$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest') -jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || { - echo "pull request effective review decision is not APPROVED" >&2 +review="$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')" +reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[][] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +review_authorized=false +if jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null && [[ "$valid_approvals" -gt 0 ]]; then + review_authorized=true +fi + +# Rule suites are GitHub's durable record that a permitted bypass actor landed +# this exact main update. The suite does not identify the matching bypass grant, +# so the Default ruleset's bypass list is itself the release-authority policy. +bypass_authorized=false +for attempt in {1..5}; do + suites="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/rulesets/rule-suites?ref=refs/heads/main&per_page=100")" + mapfile -t suite_ids < <(jq -r --arg before "$base_sha" --arg after "$MERGE_SHA" --arg actor "$MERGED_BY" ' + .[][] | select(.ref == "refs/heads/main" and .before_sha == $before and .after_sha == $after and .actor_name == $actor and .result == "bypass") | .id + ' <<<"$suites") + if [[ "${#suite_ids[@]}" -gt 1 ]]; then + echo "multiple rule suites matched the release landing" >&2 + exit 1 + fi + if [[ "${#suite_ids[@]}" -eq 1 ]]; then + suite="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/rule-suites/${suite_ids[0]}")" + if jq -e --argjson ruleset_id "$DEFAULT_RULESET_ID" -f scripts/desktop-release-bypass-authorized.jq <<<"$suite" >/dev/null; then + bypass_authorized=true + fi + break + fi + [[ "$attempt" -eq 5 ]] || sleep "$attempt" +done + +[[ "$review_authorized" == true || "$bypass_authorized" == true ]] || { + echo "release lacks an exact-head approval or authorized Default-ruleset bypass" >&2 exit 1 } -reviews="$(gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" -valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" -[[ "$valid_approvals" -gt 0 ]] || { echo "candidate lacks an exact-head approval from a repository member or collaborator" >&2; exit 1; } checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" for required in "${required_checks[@]}"; do @@ -59,4 +96,4 @@ jq -e '(.total_count == 0) or (.state == "success")' <<<"$status" >/dev/null || exit 1 } -echo "verified reviewed desktop candidate $PR_HEAD_SHA at merge $MERGE_SHA" +echo "verified desktop candidate $PR_HEAD_SHA at squash $MERGE_SHA" From 54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 12:33:54 -0600 Subject: [PATCH 45/87] fix(release): require exact-head approval for desktop tags (#3973) ## Summary - require an exact-head trusted approval before desktop auto-tagging - remove rule-suite authorization that `GITHUB_TOKEN` cannot access - pin review pagination to `page=1` and test the deployed `gh` control flow ## Why The previous verifier unconditionally queried repository rule-suite endpoints with `github.token`. Those endpoints require Administration: read, which Actions `GITHUB_TOKEN` cannot receive. Its paginated list request also duplicated page one when no explicit page was supplied. This deliberately removes admin-bypass authorization rather than introducing a second credential during release recovery. Desktop release PRs must now have GitHub's overall `APPROVED` decision and a MEMBER/OWNER/COLLABORATOR approval attached to the exact candidate SHA. ## Validation - `scripts/test-desktop-release-authorization.sh` - `scripts/test-release-ref-contract.sh` - `bash -n scripts/verify-desktop-release-merge.sh scripts/verify-desktop-release-authorization.sh scripts/test-desktop-release-authorization.sh scripts/test-release-ref-contract.sh` - `git diff --check origin/main...HEAD` The new flow test uses a stub `gh` executable, asserts the exact `page=1` request, fails any rule-suite API call, and rejects stale-SHA, untrusted-author, changes-requested review, and non-approved aggregate-decision cases. Signed-off-by: Wes Co-authored-by: Carl --- .../auto-tag-on-release-pr-merge.yml | 1 - scripts/desktop-release-bypass-authorized.jq | 10 --- .../desktop-release-rule-suite-bypass.json | 1 - scripts/test-desktop-release-authorization.sh | 69 +++++++++++++++++++ scripts/test-release-ref-contract.sh | 25 +++---- .../verify-desktop-release-authorization.sh | 15 ++++ scripts/verify-desktop-release-merge.sh | 40 +---------- 7 files changed, 93 insertions(+), 68 deletions(-) delete mode 100644 scripts/desktop-release-bypass-authorized.jq delete mode 100644 scripts/fixtures/desktop-release-rule-suite-bypass.json create mode 100755 scripts/test-desktop-release-authorization.sh create mode 100755 scripts/verify-desktop-release-authorization.sh diff --git a/.github/workflows/auto-tag-on-release-pr-merge.yml b/.github/workflows/auto-tag-on-release-pr-merge.yml index f44057268d..f31d4b835f 100644 --- a/.github/workflows/auto-tag-on-release-pr-merge.yml +++ b/.github/workflows/auto-tag-on-release-pr-merge.yml @@ -111,7 +111,6 @@ jobs: PR_HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_BASE_REF: ${{ github.event.pull_request.base.ref }} PR_HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }} - MERGED_BY: ${{ github.event.pull_request.merged_by.login }} MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }} run: | VERSION="${VERSION#desktop-v}" diff --git a/scripts/desktop-release-bypass-authorized.jq b/scripts/desktop-release-bypass-authorized.jq deleted file mode 100644 index 7dc40530b0..0000000000 --- a/scripts/desktop-release-bypass-authorized.jq +++ /dev/null @@ -1,10 +0,0 @@ -def expected_default_pull_request_rule($ruleset_id): - [.rule_evaluations[] | select( - .rule_source.type == "ruleset" and - .rule_source.id == $ruleset_id and - .enforcement == "active" and - .rule_type == "pull_request" and - .result == "fail" - )] | length == 1; - -.result == "bypass" and expected_default_pull_request_rule($ruleset_id) diff --git a/scripts/fixtures/desktop-release-rule-suite-bypass.json b/scripts/fixtures/desktop-release-rule-suite-bypass.json deleted file mode 100644 index f2aa784357..0000000000 --- a/scripts/fixtures/desktop-release-rule-suite-bypass.json +++ /dev/null @@ -1 +0,0 @@ -{"id":3520068134,"actor_id":15384764,"actor_name":"wpfleger96","before_sha":"6e02e0a9022a1a098c44ee611b4d9784addb10c7","after_sha":"209536ade6c5ebf7fa82671d7ca0b74f599a40cc","ref":"refs/heads/main","repository_id":1174789082,"repository_name":"buzz","pushed_at":"2026-07-31T11:06:21-06:00","result":"bypass","rule_evaluations":[{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"fail","rule_type":"pull_request","details":"1 review requesting changes by reviewers with write access."},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"required_status_checks"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"non_fast_forward"},{"rule_source":{"type":"ruleset","id":13596885,"name":"Default"},"enforcement":"active","result":"pass","rule_type":"deletion"}]} \ No newline at end of file diff --git a/scripts/test-desktop-release-authorization.sh b/scripts/test-desktop-release-authorization.sh new file mode 100755 index 0000000000..5a51a976be --- /dev/null +++ b/scripts/test-desktop-release-authorization.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +cat >"$tmp/bin/gh" <<'GH' +#!/usr/bin/env bash +set -euo pipefail +printf '%q ' "$@" >>"$GH_CALLS" +printf '\n' >>"$GH_CALLS" + +[[ "${1:-}" == api ]] || { echo "expected gh api" >&2; exit 91; } +if [[ "${2:-}" == graphql ]]; then + expected_query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' + [[ "$#" -eq 12 && "$3" == -f && "$4" == "query=$expected_query" && + "$5" == -F && "$6" == owner=block && + "$7" == -F && "$8" == repo=buzz && + "$9" == -F && "${10}" == number=123 && + "${11}" == --jq && "${12}" == '.data.repository.pullRequest' ]] || { + echo "GraphQL call does not match the deployed query contract" >&2; exit 92; + } + if [[ -n "${REVIEW_DECISION:-}" ]]; then printf '%s\n' "$REVIEW_DECISION"; else printf '%s\n' '{"reviewDecision":"APPROVED"}'; fi +elif [[ "$#" -eq 4 && "$2" == --paginate && "$3" == --slurp && "$4" == "repos/block/buzz/pulls/123/reviews?per_page=100&page=1" ]]; then + [[ "${GH_FAIL_REVIEWS:-false}" != true ]] || { echo "simulated reviews API failure" >&2; exit 94; } + if [[ -n "${REVIEWS:-}" ]]; then printf '%s\n' "$REVIEWS"; else printf '%s\n' '[[],[{"state":"APPROVED","commit_id":"head","author_association":"MEMBER"}]]'; fi +else + echo "unexpected or malformed gh call: $*" >&2 + exit 95 +fi +GH +chmod +x "$tmp/bin/gh" + +run_authorization() { + (cd "$repo_root" && PATH="$tmp/bin:$PATH" GH_CALLS="$tmp/calls" GH_TOKEN=test \ + GITHUB_REPOSITORY=block/buzz PR_NUMBER=123 PR_HEAD_SHA=head \ + REVIEW_DECISION="${REVIEW_DECISION-}" REVIEWS="${REVIEWS-}" GH_FAIL_REVIEWS="${GH_FAIL_REVIEWS-false}" \ + scripts/verify-desktop-release-authorization.sh) +} + +: >"$tmp/calls" +run_authorization +! grep -Fq 'rule-suites' "$tmp/calls" + +for invalid in \ + '[[{"state":"APPROVED","commit_id":"stale","author_association":"MEMBER"}]]' \ + '[[{"state":"APPROVED","commit_id":"head","author_association":"NONE"}]]' \ + '[[{"state":"CHANGES_REQUESTED","commit_id":"head","author_association":"MEMBER"}]]'; do + : >"$tmp/calls" + if REVIEWS="$invalid" run_authorization >/dev/null 2>&1; then + echo "invalid approval was accepted: $invalid" >&2 + exit 1 + fi +done + +: >"$tmp/calls" +if REVIEW_DECISION='{"reviewDecision":"CHANGES_REQUESTED"}' run_authorization >/dev/null 2>&1; then + echo "changes-requested review decision was accepted" >&2 + exit 1 +fi + +: >"$tmp/calls" +if GH_FAIL_REVIEWS=true run_authorization >/dev/null 2>&1; then + echo "reviews API failure was ignored" >&2 + exit 1 +fi + +echo "desktop release authorization passed" diff --git a/scripts/test-release-ref-contract.sh b/scripts/test-release-ref-contract.sh index 25ebf35a07..d4c0eabef7 100755 --- a/scripts/test-release-ref-contract.sh +++ b/scripts/test-release-ref-contract.sh @@ -64,24 +64,15 @@ grep -Fq 'git/refs' "$auto_tag" grep -Fq 'TAG_PREFIX="desktop-v"' "$auto_tag" grep -Fq 'target_sha=${{ github.event.pull_request.merge_commit_sha }}' "$auto_tag" grep -Fq 'scripts/verify-desktop-release-merge.sh' "$auto_tag" - -bypass_filter="$repo_root/scripts/desktop-release-bypass-authorized.jq" -bypass_fixture="$repo_root/scripts/fixtures/desktop-release-rule-suite-bypass.json" -jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" "$bypass_fixture" >/dev/null || { - echo "real squash-bypass fixture was rejected" >&2 +"$repo_root/scripts/test-desktop-release-authorization.sh" +if rg -q 'rule-suites|desktop-release-bypass-authorized|MERGED_BY' \ + "$repo_root/scripts/verify-desktop-release-merge.sh" \ + "$repo_root/scripts/verify-desktop-release-authorization.sh" \ + "$auto_tag"; then + echo "desktop auto-tag still depends on unavailable rule-suite authorization" >&2 exit 1 -} -for mutation in \ - '.result = "pass"' \ - '(.rule_evaluations[] | select(.rule_type == "pull_request")).result = "pass"' \ - '(.rule_evaluations[] | select(.rule_type == "pull_request")).rule_source.id = 0' \ - '(.rule_evaluations[] | select(.rule_type == "pull_request")).enforcement = "evaluate"' \ - 'del(.rule_evaluations[] | select(.rule_type == "pull_request"))'; do - if jq "$mutation" "$bypass_fixture" | jq -e --argjson ruleset_id 13596885 -f "$bypass_filter" >/dev/null; then - echo "bypass filter accepted invalid fixture mutation: $mutation" >&2 - exit 1 - fi -done +fi + review_filter="$repo_root/scripts/review-decision-approved.jq" for fixture in \ '{"reviewDecision":"CHANGES_REQUESTED"}' \ diff --git a/scripts/verify-desktop-release-authorization.sh b/scripts/verify-desktop-release-authorization.sh new file mode 100755 index 0000000000..b18cf6cade --- /dev/null +++ b/scripts/verify-desktop-release-authorization.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${PR_HEAD_SHA:?}" +: "${PR_NUMBER:?}" +: "${GITHUB_REPOSITORY:?}" +: "${GH_TOKEN:?}" + +review="$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')" +reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100&page=1")" +valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[][] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" +if ! jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null || [[ "$valid_approvals" -eq 0 ]]; then + echo "release lacks an exact-head approval" >&2 + exit 1 +fi diff --git a/scripts/verify-desktop-release-merge.sh b/scripts/verify-desktop-release-merge.sh index d8ca8015ba..a9fbb48d09 100755 --- a/scripts/verify-desktop-release-merge.sh +++ b/scripts/verify-desktop-release-merge.sh @@ -3,14 +3,10 @@ set -euo pipefail : "${PR_HEAD_SHA:?}" : "${MERGE_SHA:?}" -: "${MERGED_BY:?}" : "${VERSION:?}" : "${PR_NUMBER:?}" : "${GH_TOKEN:?}" -# This ID is the release-authority policy anchor. A bypass of another ruleset -# must never authorize a desktop release. -readonly DEFAULT_RULESET_ID=13596885 required_checks=( "Desktop E2E Integration" "Desktop" @@ -47,41 +43,7 @@ git merge-base --is-ancestor "$MERGE_SHA" origin/main || { echo "squash commit i git checkout --detach "$PR_HEAD_SHA" scripts/desktop_release.py validate --candidate "$PR_HEAD_SHA" --version "$VERSION" --repo "$GITHUB_REPOSITORY" -review="$(gh api graphql -f query='query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewDecision}}}' -F owner="${GITHUB_REPOSITORY%/*}" -F repo="${GITHUB_REPOSITORY#*/}" -F number="$PR_NUMBER" --jq '.data.repository.pullRequest')" -reviews="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/reviews?per_page=100")" -valid_approvals="$(jq --arg sha "$PR_HEAD_SHA" '[.[][] | select(.state == "APPROVED" and .commit_id == $sha and (.author_association == "MEMBER" or .author_association == "OWNER" or .author_association == "COLLABORATOR"))] | length' <<<"$reviews")" -review_authorized=false -if jq -e -f scripts/review-decision-approved.jq <<<"$review" >/dev/null && [[ "$valid_approvals" -gt 0 ]]; then - review_authorized=true -fi - -# Rule suites are GitHub's durable record that a permitted bypass actor landed -# this exact main update. The suite does not identify the matching bypass grant, -# so the Default ruleset's bypass list is itself the release-authority policy. -bypass_authorized=false -for attempt in {1..5}; do - suites="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/rulesets/rule-suites?ref=refs/heads/main&per_page=100")" - mapfile -t suite_ids < <(jq -r --arg before "$base_sha" --arg after "$MERGE_SHA" --arg actor "$MERGED_BY" ' - .[][] | select(.ref == "refs/heads/main" and .before_sha == $before and .after_sha == $after and .actor_name == $actor and .result == "bypass") | .id - ' <<<"$suites") - if [[ "${#suite_ids[@]}" -gt 1 ]]; then - echo "multiple rule suites matched the release landing" >&2 - exit 1 - fi - if [[ "${#suite_ids[@]}" -eq 1 ]]; then - suite="$(gh api "repos/$GITHUB_REPOSITORY/rulesets/rule-suites/${suite_ids[0]}")" - if jq -e --argjson ruleset_id "$DEFAULT_RULESET_ID" -f scripts/desktop-release-bypass-authorized.jq <<<"$suite" >/dev/null; then - bypass_authorized=true - fi - break - fi - [[ "$attempt" -eq 5 ]] || sleep "$attempt" -done - -[[ "$review_authorized" == true || "$bypass_authorized" == true ]] || { - echo "release lacks an exact-head approval or authorized Default-ruleset bypass" >&2 - exit 1 -} +scripts/verify-desktop-release-authorization.sh checks="$(gh api --paginate --slurp "repos/$GITHUB_REPOSITORY/commits/$PR_HEAD_SHA/check-runs?per_page=100")" for required in "${required_checks[@]}"; do From 3a96acea09b4a9e3f02c3a26cfb0607d2ccacf42 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 13:07:31 -0600 Subject: [PATCH 46/87] chore(release): release Buzz Desktop version 0.5.3 (#3972) ## Buzz Desktop release v0.5.3 - **Frozen main:** `54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a` - **Reviewed candidate:** `d0c06978bbf494ded6fe1a55d69d810ae9b65863` - **Previous desktop release:** `v0.5.2` - **Proposed immutable tag:** `desktop-v0.5.3` This PR must be **squash merged** only after the Desktop Release Candidate check passes. The branch must remain based directly on current ; stale base, payload drift, incomplete notes, or an unauthorized merge produce no tag. The checked-in changelog accounts for every non-merge commit in the release range. Publication remains bound to the immutable candidate tag. Signed-off-by: Wes Co-authored-by: Release Automation --- .release/desktop-candidate.json | 8 ++++ CHANGELOG.md | 68 +++++++++++++++++++++++++++++++ desktop/package.json | 2 +- desktop/src-tauri/Cargo.lock | 2 +- desktop/src-tauri/Cargo.toml | 2 +- desktop/src-tauri/tauri.conf.json | 2 +- 6 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 .release/desktop-candidate.json diff --git a/.release/desktop-candidate.json b/.release/desktop-candidate.json new file mode 100644 index 0000000000..1bf2efb66b --- /dev/null +++ b/.release/desktop-candidate.json @@ -0,0 +1,8 @@ +{ + "schema": 1, + "version": "0.5.3", + "base_sha": "54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a", + "previous_tag": "v0.5.2", + "tag": "desktop-v0.5.3", + "commit_count": 58 +} diff --git a/CHANGELOG.md b/CHANGELOG.md index d83087fc26..71a4bbd449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## v0.5.3 + +### Desktop and shared changes + +- Revert "chore(release): release Buzz Desktop version 0.5.3" ([#3960](https://github.com/block/buzz/pull/3960)) ([`bb34bc4d98fe4dabe847046103ac5e2859917ac5`](https://github.com/block/buzz/commit/bb34bc4d98fe4dabe847046103ac5e2859917ac5)) +- chore(release): release Buzz Desktop version 0.5.3 ([`d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131`](https://github.com/block/buzz/commit/d12b3d6a79d56a95fc99ce4fadd2d2235d5a3131)) +- feat(desktop): import local Pocket voices ([#3259](https://github.com/block/buzz/pull/3259)) ([`c104eecfb38620de2c35c7e20a716f8658b5a6b1`](https://github.com/block/buzz/commit/c104eecfb38620de2c35c7e20a716f8658b5a6b1)) +- fix(desktop): open profiles from avatars ([#3751](https://github.com/block/buzz/pull/3751)) ([`39ce3dfc3cf2d12f0d6c64b4cd4293df86567663`](https://github.com/block/buzz/commit/39ce3dfc3cf2d12f0d6c64b4cd4293df86567663)) +- refactor(voice): extract reusable Pocket primitives + Pocket voice settings (relands #2467 + #3208) ([#3910](https://github.com/block/buzz/pull/3910)) ([`61ba9dfaa00852925058d1a024322fa53663a5bc`](https://github.com/block/buzz/commit/61ba9dfaa00852925058d1a024322fa53663a5bc)) +- feat(desktop): auto-enable huddle transcription for agents ([#3180](https://github.com/block/buzz/pull/3180)) ([`4632c55041c5d423d572a6f6411bb7b279c26f67`](https://github.com/block/buzz/commit/4632c55041c5d423d572a6f6411bb7b279c26f67)) +- feat(agent): optional reply guard reminds a silent turn to publish ([#3763](https://github.com/block/buzz/pull/3763)) ([`081f805d5ea25841ab885c7b67a568618a34aa59`](https://github.com/block/buzz/commit/081f805d5ea25841ab885c7b67a568618a34aa59)) +- feat(desktop): upgrade Pocket TTS model ([#3266](https://github.com/block/buzz/pull/3266)) ([`d48b0e0eec4d2958f90a3cafa9d974450abe8501`](https://github.com/block/buzz/commit/d48b0e0eec4d2958f90a3cafa9d974450abe8501)) +- feat(desktop): delete a message by clearing its edit to empty ([#3813](https://github.com/block/buzz/pull/3813)) ([`d88313f369acfa17973029787ee4c0bbea07fa51`](https://github.com/block/buzz/commit/d88313f369acfa17973029787ee4c0bbea07fa51)) +- feat(relay): raise hosted community limit to five ([#3829](https://github.com/block/buzz/pull/3829)) ([`10d5a26414dc90dc89fd27de74b21e105d4fa622`](https://github.com/block/buzz/commit/10d5a26414dc90dc89fd27de74b21e105d4fa622)) +- feat(desktop): locally stored NIP-49 encrypted key backup ([#2937](https://github.com/block/buzz/pull/2937)) ([`468647a51f858b29d27eaf9fd07bf90294f99d39`](https://github.com/block/buzz/commit/468647a51f858b29d27eaf9fd07bf90294f99d39)) +- fix(catalog): update Amp tagline ([#3806](https://github.com/block/buzz/pull/3806)) ([`f3e5e812677f6f14bffe16a7aa02642d56faca4b`](https://github.com/block/buzz/commit/f3e5e812677f6f14bffe16a7aa02642d56faca4b)) +- fix(desktop): channel topic and membership metadata cleanup ([#3642](https://github.com/block/buzz/pull/3642)) ([`9e8fcfda099652926b921bca7fcc9bfecab0e140`](https://github.com/block/buzz/commit/9e8fcfda099652926b921bca7fcc9bfecab0e140)) +- fix(desktop): align data deletion labels ([#2230](https://github.com/block/buzz/pull/2230)) ([`ede26863345a518ec46edd6d7692e0281883491b`](https://github.com/block/buzz/commit/ede26863345a518ec46edd6d7692e0281883491b)) +- fix(desktop): allow linux-only media items as dead code off-linux ([#3811](https://github.com/block/buzz/pull/3811)) ([`36571f4adcfdcf3714a17bd968c58c78bcbdd9ef`](https://github.com/block/buzz/commit/36571f4adcfdcf3714a17bd968c58c78bcbdd9ef)) +- fix(desktop): report authenticated relay recovery ([#3812](https://github.com/block/buzz/pull/3812)) ([`74cd5712191bffd84ae688d59bb8b451c6eec1b0`](https://github.com/block/buzz/commit/74cd5712191bffd84ae688d59bb8b451c6eec1b0)) +- fix(desktop): don't gate hover affordances on the hover media query ([#3657](https://github.com/block/buzz/pull/3657)) ([`29dfe4821ed577489a1879fd2a9bfe2a621a52b3`](https://github.com/block/buzz/commit/29dfe4821ed577489a1879fd2a9bfe2a621a52b3)) +- feat(relay): gate kind 30178 team-catalog reads behind the shared tag ([#3358](https://github.com/block/buzz/pull/3358)) ([`114d40d9d37f05eff83ee90347ed93fb3da512c5`](https://github.com/block/buzz/commit/114d40d9d37f05eff83ee90347ed93fb3da512c5)) +- test(desktop): click visible thread collapse guide ([#3800](https://github.com/block/buzz/pull/3800)) ([`b9e4ed616f39b812bc964e79c7a40223c4e93832`](https://github.com/block/buzz/commit/b9e4ed616f39b812bc964e79c7a40223c4e93832)) +- feat(desktop): raise the install ceiling and make installs observable ([#3368](https://github.com/block/buzz/pull/3368)) ([`d40a33290e75791aa7ecf3ce7a252b66c2e35966`](https://github.com/block/buzz/commit/d40a33290e75791aa7ecf3ce7a252b66c2e35966)) +- Add Devin as a preset ACP harness ([#3225](https://github.com/block/buzz/pull/3225)) ([`1b3ff96a5764303998fa629ff852e81f1a88d7ad`](https://github.com/block/buzz/commit/1b3ff96a5764303998fa629ff852e81f1a88d7ad)) +- feat(desktop): improve agent activity header ui ([#3321](https://github.com/block/buzz/pull/3321)) ([`4d47aa83455a9fd024121a596154cd311dca1d76`](https://github.com/block/buzz/commit/4d47aa83455a9fd024121a596154cd311dca1d76)) +- perf(presence): reduce heartbeat frequency ([#3783](https://github.com/block/buzz/pull/3783)) ([`bf139e8d0bdba10df9a5adbf16843140e0a78a59`](https://github.com/block/buzz/commit/bf139e8d0bdba10df9a5adbf16843140e0a78a59)) +- Tighten continuation message rows ([#3724](https://github.com/block/buzz/pull/3724)) ([`6e419b9f1c873549a7b40996970e0da7352adafb`](https://github.com/block/buzz/commit/6e419b9f1c873549a7b40996970e0da7352adafb)) +- Fix video reviews in thread replies ([#3719](https://github.com/block/buzz/pull/3719)) ([`f48f3f055fdd6030d3832f615f8c0d8e5a81261a`](https://github.com/block/buzz/commit/f48f3f055fdd6030d3832f615f8c0d8e5a81261a)) +- Make relay reconnect backoff authoritative ([#3774](https://github.com/block/buzz/pull/3774)) ([`cca8839034eb571a7ce943c3ace7f85a82330898`](https://github.com/block/buzz/commit/cca8839034eb571a7ce943c3ace7f85a82330898)) +- feat(desktop): add password-protected backups in settings ([#3701](https://github.com/block/buzz/pull/3701)) ([`bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c`](https://github.com/block/buzz/commit/bd0bff24bfd2cffa2b3b3a995f7628af5e460a5c)) +- fix(desktop): reuse profiles when joining communities ([#2155](https://github.com/block/buzz/pull/2155)) ([`f44b5a2477f3979ae66e49153b11be36538cf859`](https://github.com/block/buzz/commit/f44b5a2477f3979ae66e49153b11be36538cf859)) +- fix(catalog): update Amp description ([#3758](https://github.com/block/buzz/pull/3758)) ([`61b96c9828d1dd54106b570d87a54edbc92bb9c4`](https://github.com/block/buzz/commit/61b96c9828d1dd54106b570d87a54edbc92bb9c4)) +- feat(catalog): resolve publisher display name in catalog detail pane ([#3640](https://github.com/block/buzz/pull/3640)) ([`02be413b823c356587e6e9f4d07f6cb06bb41c3c`](https://github.com/block/buzz/commit/02be413b823c356587e6e9f4d07f6cb06bb41c3c)) +- feat(mesh): upgrade embedded mesh to v0.74 and harden shared compute (split 1/2 of #3467) ([#3741](https://github.com/block/buzz/pull/3741)) ([`4933672eb4589e7208b312829ebddcd10dfa9dd3`](https://github.com/block/buzz/commit/4933672eb4589e7208b312829ebddcd10dfa9dd3)) +- Refine agent sharing dialog ([#3699](https://github.com/block/buzz/pull/3699)) ([`9a386a0defbf2b355ee17646c7c11817a535b85f`](https://github.com/block/buzz/commit/9a386a0defbf2b355ee17646c7c11817a535b85f)) +- desktop: enable getUserMedia in the Linux WebKitGTK webview ([#3607](https://github.com/block/buzz/pull/3607)) ([`c9aa55505c544c608ff71648bbfd21b235637f19`](https://github.com/block/buzz/commit/c9aa55505c544c608ff71648bbfd21b235637f19)) +- fix: align responsive agent views ([#3688](https://github.com/block/buzz/pull/3688)) ([`73589408db6fd96b87ac570935d414ecc4120f53`](https://github.com/block/buzz/commit/73589408db6fd96b87ac570935d414ecc4120f53)) +- Add macOS agent menu-bar menu ([#3565](https://github.com/block/buzz/pull/3565)) ([`d0a24bcb5210326da4c0b1e749ee3935621b329c`](https://github.com/block/buzz/commit/d0a24bcb5210326da4c0b1e749ee3935621b329c)) +- Fix pending message feedback ([#3543](https://github.com/block/buzz/pull/3543)) ([`4672ee55c4e4a7916c31bfeae5df2fb4384bed10`](https://github.com/block/buzz/commit/4672ee55c4e4a7916c31bfeae5df2fb4384bed10)) +- fix(desktop): remove remaining Projects panel fills ([#3742](https://github.com/block/buzz/pull/3742)) ([`c55e421a0629c74b9ffd96ee3ccde36f006196ed`](https://github.com/block/buzz/commit/c55e421a0629c74b9ffd96ee3ccde36f006196ed)) +- desktop: restore direct community member adds ([#3634](https://github.com/block/buzz/pull/3634)) ([`310df2ec33fbb075edf226ba18bf9a96d90ba81b`](https://github.com/block/buzz/commit/310df2ec33fbb075edf226ba18bf9a96d90ba81b)) +- fix(desktop): explain open agent access ([#2561](https://github.com/block/buzz/pull/2561)) ([`7fb008f9347b933b9a1da20a7afb070912b430e8`](https://github.com/block/buzz/commit/7fb008f9347b933b9a1da20a7afb070912b430e8)) +- fix(desktop): remove Projects overview card fills ([#3416](https://github.com/block/buzz/pull/3416)) ([`3b8567a05d4c40e667d061666feb7aa7bc38212d`](https://github.com/block/buzz/commit/3b8567a05d4c40e667d061666feb7aa7bc38212d)) +- fix(git): channel binding tooling + author remediation for unbound repos ([#3626](https://github.com/block/buzz/pull/3626)) ([`788b3c002bd2509455444f57f8a03a054b4b496a`](https://github.com/block/buzz/commit/788b3c002bd2509455444f57f8a03a054b4b496a)) +- feat: configure S3 URL addressing style ([#3400](https://github.com/block/buzz/pull/3400)) ([`7012d86d52fd188b27c7beedeaa132d9c1f61fa8`](https://github.com/block/buzz/commit/7012d86d52fd188b27c7beedeaa132d9c1f61fa8)) +- feat: add first-class OpenRouter provider support ([#1975](https://github.com/block/buzz/pull/1975)) ([`ab55fee81896d2b03edf5d2ca5012b715be2b93d`](https://github.com/block/buzz/commit/ab55fee81896d2b03edf5d2ca5012b715be2b93d)) +- feat(agent,acp): wire provider total_tokens through NIP-AM publish chain ([#3593](https://github.com/block/buzz/pull/3593)) ([`f95fdc1a102e17c6718a44323d9a2feaed702db7`](https://github.com/block/buzz/commit/f95fdc1a102e17c6718a44323d9a2feaed702db7)) + +### Other repository changes + +- fix(release): require exact-head approval for desktop tags ([#3973](https://github.com/block/buzz/pull/3973)) ([`54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a`](https://github.com/block/buzz/commit/54c8ef30a9bb9c59a4415a8a7ee84c7c5454b48a)) +- fix(release): make desktop tagging squash-safe ([#3965](https://github.com/block/buzz/pull/3965)) ([`db7e84d4f815127236b9cb080c5d374f48eaac09`](https://github.com/block/buzz/commit/db7e84d4f815127236b9cb080c5d374f48eaac09)) +- docs(nips): add single-coordinate manual-unread override layer and verification model to NIP-RS ([#2864](https://github.com/block/buzz/pull/2864)) ([`209536ade6c5ebf7fa82671d7ca0b74f599a40cc`](https://github.com/block/buzz/commit/209536ade6c5ebf7fa82671d7ca0b74f599a40cc)) +- fix(release): make immutable desktop release operable ([#3943](https://github.com/block/buzz/pull/3943)) ([`052174a148f9f6bcbb2b5a1d20ce0317645e49f8`](https://github.com/block/buzz/commit/052174a148f9f6bcbb2b5a1d20ce0317645e49f8)) +- docs: add VISION_REMOTE_AGENTS.md ([#3924](https://github.com/block/buzz/pull/3924)) ([`689617af7ad420c3266d5d2eb437757371327089`](https://github.com/block/buzz/commit/689617af7ad420c3266d5d2eb437757371327089)) +- fix(relay): align NIP-11 max_limit with REQ ceiling ([#3635](https://github.com/block/buzz/pull/3635)) ([`23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9`](https://github.com/block/buzz/commit/23f0c26b1ceba8e07bf3c160a1e08c7bda82ccd9)) +- fix(db): isolate usage metrics advisory-lock test on scratch DB ([#3670](https://github.com/block/buzz/pull/3670)) ([`dba97eecd9d8659c9c816cd6666fa6d687b6bca1`](https://github.com/block/buzz/commit/dba97eecd9d8659c9c816cd6666fa6d687b6bca1)) +- feat(release): make desktop releases immutable ([#3568](https://github.com/block/buzz/pull/3568)) ([`1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a`](https://github.com/block/buzz/commit/1dfd89ea67b4ebce0c4d10390f280ed4e7ddde8a)) +- Render mobile agent mention chips ([#3702](https://github.com/block/buzz/pull/3702)) ([`06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a`](https://github.com/block/buzz/commit/06582ee6f09e5f7454e4d8895d80a45c3cdb5e8a)) +- fix(acp): preserve truncated thread context ([#3340](https://github.com/block/buzz/pull/3340)) ([`53771c8f5439f9c5c26876f0229bfcfe5da9b170`](https://github.com/block/buzz/commit/53771c8f5439f9c5c26876f0229bfcfe5da9b170)) +- docs(nips): specify kind:30621 multi-repo projects (NIP-MP) ([#3163](https://github.com/block/buzz/pull/3163)) ([`33bf7caa6ea474ccde2932c1ed05a90d7345c6e0`](https://github.com/block/buzz/commit/33bf7caa6ea474ccde2932c1ed05a90d7345c6e0)) +- feat(mobile): desktop-parity emoji and thread experience ([#3485](https://github.com/block/buzz/pull/3485)) ([`85edc0572a8540dedfa6562d40f0f875af0b5f61`](https://github.com/block/buzz/commit/85edc0572a8540dedfa6562d40f0f875af0b5f61)) +- fix(cli): resolve agents from owner records ([#3178](https://github.com/block/buzz/pull/3178)) ([`262f2392e3b7e09c78d582fb384672034d8551d5`](https://github.com/block/buzz/commit/262f2392e3b7e09c78d582fb384672034d8551d5)) +- feat(replica): portable heartbeat-token fence with snapshot-local reader routing ([#3268](https://github.com/block/buzz/pull/3268)) ([`63496cc1d4c6f1b7c613801bdcc694169dcf391a`](https://github.com/block/buzz/commit/63496cc1d4c6f1b7c613801bdcc694169dcf391a)) + +[Compare v0.5.2...desktop-v0.5.3](https://github.com/block/buzz/compare/v0.5.2...desktop-v0.5.3) + ## v0.5.2 - feat(cli): mirror Desktop mention delivery ([#3330](https://github.com/block/buzz/pull/3330)) ([`7adc46268`](https://github.com/block/buzz/commit/7adc46268d5e93f0b1d4dc8e700af22815dcac1b)) diff --git a/desktop/package.json b/desktop/package.json index 2226a0cb12..e8145f5468 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,7 +1,7 @@ { "name": "buzz", "private": true, - "version": "0.5.2", + "version": "0.5.3", "type": "module", "scripts": { "dev": "vite", diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index 254b7070ac..00d3fba3b5 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1036,7 +1036,7 @@ dependencies = [ [[package]] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" dependencies = [ "anyhow", "arboard", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 39aaf0dead..b80684f955 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -2,7 +2,7 @@ [package] name = "buzz-desktop" -version = "0.5.2" +version = "0.5.3" description = "Buzz desktop app" authors = ["you"] edition = "2021" diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index 2eba7815b2..1ff8bd20ef 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Buzz", - "version": "0.5.2", + "version": "0.5.3", "identifier": "xyz.block.buzz.app", "build": { "beforeDevCommand": { From e5e5bac2a932b2b2e4eb6b559d5545a992c21b96 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 31 Jul 2026 13:22:53 -0600 Subject: [PATCH 47/87] fix(release): preserve main in desktop PR body (#3979) ## Summary - escape the Markdown backticks around `main` in the desktop release PR body - prevent the shell from executing `main` as command substitution - lock the heredoc contract into the release-ref test ## Verification - `scripts/test-release-ref-contract.sh` - `bash -n scripts/prepare-desktop-release.sh scripts/test-release-ref-contract.sh` - `git diff --check origin/main...HEAD` This is a follow-up to the cosmetic PR-body issue observed on #3972. It does not modify that frozen release candidate. Signed-off-by: Wes Co-authored-by: Carl --- scripts/prepare-desktop-release.sh | 2 +- scripts/test-release-ref-contract.sh | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/prepare-desktop-release.sh b/scripts/prepare-desktop-release.sh index 9a6f4d1cfa..fb586005fb 100755 --- a/scripts/prepare-desktop-release.sh +++ b/scripts/prepare-desktop-release.sh @@ -71,7 +71,7 @@ cat >"$body" <&2 + exit 1 +fi "$repo_root/scripts/test-desktop-release-authorization.sh" if rg -q 'rule-suites|desktop-release-bypass-authorized|MERGED_BY' \ "$repo_root/scripts/verify-desktop-release-merge.sh" \ From cb9701cd30fb344bf134585634a09007f3155bfb Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 31 Jul 2026 16:22:57 -0400 Subject: [PATCH 48/87] feat(relay): accept kind:30621 multi-repo projects at ingest (#3171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz renders one card per `kind:30617`, so a project spanning several repositories has no representation. [NIP-MP](https://github.com/block/buzz/pull/3163) defines `kind:30621` as an addressable container holding a group's name, description, channel binding, and member coordinates. This adds the kind to `buzz-core` and its structural validation to the relay ingest path. ## Event shape ```json { "kind": 30621, "tags": [ ["d", "platform"], ["name", "Platform"], ["description", "Relay, desktop, and mobile."], ["a", "30617::buzz"], ["a", "30617::buzz-infra"], ["buzz-channel", ""], ["buzz-visibility", "listed"] ] } ``` ## Validation at ingest | Rule | Behavior | |------|----------| | `d` tag | exactly one, non-empty (length already bounded by the generic `D_TAG_MAX_LEN` check) | | member `a` tag arity | exactly 2 or 3 elements per NIP-01's `a` tag grammar; a 4th element has no defined meaning and is rejected | | member `a` tag coordinate | must parse as `30617::` | | duplicate members | rejected on exact string match of the canonical coordinate | | member cap | 64, counted over raw `a` tags | | metadata cardinality | at most one each of `name`, `description`, `buzz-channel`, `buzz-visibility` | | metadata length | `name` ≤ 256 bytes, `description` ≤ 2048 bytes, `buzz-channel` ≤ 256 bytes, `buzz-visibility` ≤ 256 bytes | | zero members | valid | | unknown tags | ignored | Rejection order is normative so a client can predict which rule fires: `d`-cardinality → `d`-empty → member-cap → member-arity → coordinate parse → member-duplicate → metadata cardinality → metadata length. ## Design notes **No membership authorization.** Members are `a` tags, so one project may name repositories owned by different pubkeys — the entire point of the kind. That is safe because membership grants nothing: push policy reads a repository's own `kind:30617` (`api/git/policy.rs`) and never a project. `buzz-channel` is a metadata reference, not a routing directive, so projects are classified global-only. **Owner-only editing is free.** NIP-33 addressing keys replacement on `(pubkey, kind, d)`, so one signer can never overwrite another's project. No relay-side permission check exists or is needed, and `test_project_same_d_under_two_authors_are_independent` pins it. **Duplicates are rejected, not deduped.** A relay cannot rewrite tags inside a signed event without invalidating its id and signature, so the alternative to rejection is a stored duplicate-member head that every consumer must apply a first-wins rule to. **The cap is checked before the duplicate set is built.** Counting raw `a` tags rather than distinct coordinates means an event naming one coordinate thousands of times is refused on count, instead of being bounded only by the relay frame limit. **No side-effect handler.** Generic NIP-33 replacement and generic NIP-09 coordinate soft-delete already cover replacement and deletion; `kind:30621` needs no entry in `is_side_effect_kind`. ## Generic NIP-09 fix carried along `soft_delete_by_coordinate` (`crates/buzz-db/src/event.rs`) previously deleted the live coordinate head regardless of the tombstone's own `created_at`, so a delayed or replayed `a`-tag deletion signed between two versions destroyed the newer replacement. NIP-09 scopes an `a`-tag deletion to versions at or before the deletion request, so the `UPDATE` now carries `created_at <= $5` and `handle_a_tag_deletion` threads the deletion event's `created_at` through. The bug predates `kind:30621` and affected every parameterized-replaceable kind on the generic path — `kind:30617` repository announcements included — so the fix lands there rather than as a project special case. `events.created_at` is immutable per row, so the predicate guarantees a tombstone can never erase a version newer than itself; the UPDATE re-evaluates its WHERE clause after any lock wait. Under READ COMMITTED, a same-coordinate replacement racing the deletion may cause the deletion to evaluate before the new head lands, returning `Ok(false)` — but that outcome is state-identical to the deletion having arrived first, a valid Nostr ordering Nostr never fixes. The return value feeds only a debug log. No coordinate-level lock is needed. ## Coverage 32 unit tests in `crates/buzz-relay/src/handlers/ingest.rs` pin the envelope contract (accept: minimal, cross-owner, zero-member, same repo `d` under two owners, colon-bearing repo `d`, cap boundary, unknown tags, relay hint on member `a` tag, max-length metadata, stranger-owned member, uninterpreted metadata values, non-empty content; reject: every rule above plus valueless `d`/`a` tags). A fixture-driven test (`project_envelope_validates_all_shared_fixtures`) runs every case in the shared `NIP-MP.fixtures.json` oracle (11 accept + 20 reject) against `validate_project_envelope`, so any future change that breaks a case turns the test suite red. 6 `#[ignore]`d e2e tests in `crates/buzz-test-client/tests/e2e_project.rs` cover behavior that only exists past storage — coordinate round-trip, newer-wins replacement, two authors sharing a `d`, an `a`-tag tombstone that removes the project while leaving referenced `kind:30617`s intact, and a tombstone timestamped between V1 and V2 that must leave V2 live. The negative e2e case asserts on the rejection message so a refusal for an unrelated reason cannot satisfy it; that is what proves the validator is reachable from the live write path rather than merely correct in isolation. The new e2e binary is wired into the Relay E2E job. The timestamp predicate is additionally pinned at the storage layer by `coordinate_delete_spares_head_newer_than_the_deletion` in `crates/buzz-db/src/lib.rs`, which asserts both directions: a stale tombstone deletes nothing and leaves the newer head readable, and a tombstone at the head's own timestamp still deletes it. This test is wired into the Backend Integration job. Related: #3163 (the NIP-MP spec and shared conformance fixtures). Independent — either can merge first. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 --- .github/workflows/ci.yml | 13 +- crates/buzz-core/src/kind.rs | 11 + crates/buzz-db/src/event.rs | 29 +- crates/buzz-db/src/lib.rs | 86 ++- crates/buzz-relay/src/api/git/transport.rs | 14 +- crates/buzz-relay/src/handlers/ingest.rs | 693 +++++++++++++++++- .../buzz-relay/src/handlers/side_effects.rs | 11 +- crates/buzz-test-client/tests/e2e_project.rs | 491 +++++++++++++ docs/nips/NIP-MP.md | 4 +- 9 files changed, 1338 insertions(+), 14 deletions(-) create mode 100644 crates/buzz-test-client/tests/e2e_project.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc594e16ad..453a15de99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -704,6 +704,17 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 + - name: NIP-MP coordinate deletion guard + # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: + # a stale tombstone (created_at earlier than the live head) spares that + # head, and an equal-timestamp tombstone deletes it. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ + --run-ignored ignored-only + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -739,7 +750,7 @@ jobs: ./scripts/start-relay-for-tests.sh --no-build - name: Relay E2E tests run: | - cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop -- --ignored --nocapture + cargo test -p buzz-test-client --test e2e_persona --test e2e_team_catalog --test e2e_nostr_interop --test e2e_project -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay invite -- --ignored --nocapture cargo test -p buzz-test-client --test e2e_relay nip43_membership_snapshots_are_rejected -- --ignored --nocapture env: diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index e5f67f671f..b1be7c5038 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -609,6 +609,15 @@ pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; /// NIP-34: Status — Draft. pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; +/// NIP-MP: Multi-repo project — a named grouping of `kind:30617` repository +/// announcements (parameterized replaceable, d=project slug). +/// +/// Members are `a` tags holding `30617::` coordinates, so one +/// project may span repositories owned by different pubkeys. The signer gains no +/// authority over any member: push policy reads the repository's own +/// announcement, never a project. See `docs/nips/NIP-MP.md`. +pub const KIND_PROJECT: u32 = 30621; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -739,6 +748,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, + KIND_PROJECT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -836,6 +846,7 @@ const _: () = assert!(is_parameterized_replaceable(KIND_TEAM_CATALOG)); // 30178 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 +const _: () = assert!(is_parameterized_replaceable(KIND_PROJECT)); // 30621 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_THREAD_SUMMARY)); // 39005 ∈ 30000–39999 const _: () = assert!(is_parameterized_replaceable(KIND_WINDOW_BOUNDS)); // 39006 ∈ 30000–39999 diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 6c84950a2c..a670a13402 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -789,7 +789,8 @@ pub async fn soft_delete_event( } /// Soft-delete the live row for an addressable coordinate -/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key. +/// `(kind, pubkey, d_tag)` — the NIP-33 replacement key — provided it is not +/// newer than the deletion request. /// /// Used by `handle_a_tag_deletion` to honour NIP-09 a-tag deletions for any /// parameterized-replaceable kind. The WHERE clause mirrors @@ -797,23 +798,45 @@ pub async fn soft_delete_event( /// `channel_id` is intentionally NOT in the key (NIP-33 replacement is global /// per the spec — `channel_id` is stored for query scoping, not identity). /// +/// `deletion_created_at_secs` is the deletion event's own `created_at`. NIP-09 +/// scopes an `a`-tag deletion to versions at or before that instant, so a +/// delayed or replayed tombstone signed between two versions must not erase the +/// newer replacement. `events.created_at` is immutable per row, so the predicate +/// guarantees a tombstone can never erase a version newer than itself — the UPDATE +/// re-evaluates its WHERE clause after any lock wait, so a replacement that races +/// the deletion and lands with a later `created_at` is always spared. +/// +/// This does NOT guarantee deletion completeness when a same-coordinate +/// replacement races the deletion: the deletion may evaluate its predicate before +/// the replacement arrives, miss the incoming head, and return `Ok(false)`. That +/// outcome is state-identical to the deletion having arrived first (old head +/// gone, new head present), which is a valid Nostr ordering — Nostr never fixes +/// the order of concurrent writes from different signers, and even same-signer +/// ordering is advisory. The return value feeds only a debug log, not a +/// correctness gate. +/// /// Returns `Ok(true)` if a row was deleted, `Ok(false)` if no live row matched -/// (already deleted, or never existed). +/// (already deleted, never existed, or strictly newer than the deletion). pub async fn soft_delete_by_coordinate( pool: &PgPool, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { + let deletion_created_at = DateTime::from_timestamp(deletion_created_at_secs, 0) + .ok_or(DbError::InvalidTimestamp(deletion_created_at_secs))?; let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ + AND created_at <= $5", ) .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) + .bind(deletion_created_at) .execute(pool) .await?; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 50aac1cbaf..245e49bb2d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1813,16 +1813,27 @@ impl Db { event::soft_delete_event(&self.pool, community_id, event_id).await } - /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. - /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. + /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)` + /// when it is not newer than the deletion request. + /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds; + /// `deletion_created_at_secs` is the deletion event's `created_at`. pub async fn soft_delete_by_coordinate( &self, community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, + deletion_created_at_secs: i64, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate( + &self.pool, + community_id, + kind, + pubkey, + d_tag, + deletion_created_at_secs, + ) + .await } /// Atomically soft-delete an event and decrement thread reply counters. @@ -5227,6 +5238,75 @@ mod tests { ); } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn coordinate_delete_spares_head_newer_than_the_deletion() { + use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; + + let db = setup_db().await; + let community = CommunityId::from_uuid(make_community(&db.pool).await); + let keys = Keys::generate(); + let kind = buzz_core::kind::KIND_PROJECT as i32; + let d_tag = "stale-tombstone-project"; + let pubkey = keys.public_key().to_bytes().to_vec(); + let base = Timestamp::now().as_secs(); + + let version = |content: &str, offset: u64| { + EventBuilder::new(Kind::Custom(buzz_core::kind::KIND_PROJECT as u16), content) + .tags(vec![Tag::parse(["d", d_tag]).expect("d tag")]) + .custom_created_at(Timestamp::from(base + offset)) + .sign_with_keys(&keys) + .expect("sign project version") + }; + + for (content, offset) in [("v1", 0), ("v2", 100)] { + assert!( + db.replace_parameterized_event(community, &version(content, offset), d_tag, None) + .await + .expect("store project version") + .1 + ); + } + + // Tombstone timestamped between V1 and V2: it authorizes deleting V1, + // never the newer head that replaced it. + let stale_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 50) as i64) + .await + .expect("stale coordinate delete"); + assert!( + !stale_deleted, + "a tombstone older than the live head must delete nothing" + ); + + let live_content: Option = sqlx::query_scalar( + "SELECT content FROM events \ + WHERE community_id=$1 AND kind=$2 AND pubkey=$3 AND d_tag=$4 AND deleted_at IS NULL", + ) + .bind(community.as_uuid()) + .bind(kind) + .bind(&pubkey) + .bind(d_tag) + .fetch_optional(&db.pool) + .await + .expect("read live head"); + assert_eq!( + live_content.as_deref(), + Some("v2"), + "the newer head must survive a stale tombstone" + ); + + // A tombstone at or after the head's own timestamp still deletes it. + let current_deleted = db + .soft_delete_by_coordinate(community, kind, &pubkey, d_tag, (base + 100) as i64) + .await + .expect("current coordinate delete"); + assert!( + current_deleted, + "a tombstone at the head's timestamp must delete it (NIP-09 is at-or-before)" + ); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn duplicate_nip_rs_discriminator_tags_keep_legacy_retention() { diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index 11c4f6d35b..f8e0300277 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2795,10 +2795,18 @@ mod sec005_read_gate_tests { ); let owner_pk = f.owner_keys.public_key().to_bytes().to_vec(); + // Tombstone timestamped after the announcement, per NIP-09's + // at-or-before scoping in `soft_delete_by_coordinate`. let deleted = - f.db.soft_delete_by_coordinate(f.community, 30617, &owner_pk, &f.repo) - .await - .expect("soft delete 30617"); + f.db.soft_delete_by_coordinate( + f.community, + 30617, + &owner_pk, + &f.repo, + chrono::Utc::now().timestamp() + 60, + ) + .await + .expect("soft delete 30617"); assert!(deleted, "precondition: a live announcement row was deleted"); assert!( diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 39ecbe18e4..fcd0d70728 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -28,7 +28,7 @@ use buzz_core::kind::{ KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_NIP65_RELAY_LIST_METADATA, KIND_PERSONA, KIND_PIN_LIST, KIND_PRESENCE_UPDATE, - KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, + KIND_PRODUCT_FEEDBACK, KIND_PROFILE, KIND_PROJECT, KIND_REACTION, KIND_READ_STATE, KIND_REPORT, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEAM, KIND_TEAM_CATALOG, KIND_TEXT_NOTE, @@ -301,6 +301,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), // NIP-34: Git repository events KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), + // NIP-MP: a project is repository metadata — grouping repositories needs + // the same scope as announcing them. + KIND_PROJECT => Ok(Scope::ReposWrite), KIND_GIT_PATCH | KIND_GIT_PULL_REQUEST | KIND_GIT_PR_UPDATE @@ -437,6 +440,10 @@ pub(crate) fn is_global_only_kind(kind: u32) -> bool { | KIND_GIT_STATUS_MERGED | KIND_GIT_STATUS_CLOSED | KIND_GIT_STATUS_DRAFT + // NIP-MP: projects are addressed by (pubkey, kind, d_tag). The + // `buzz-channel` tag is a metadata reference, not a routing directive, + // so a project's state is never channel-scoped. + | KIND_PROJECT // Community moderation commands (9040–9044): community-global // direct commands, same model as the NIP-43 9030-series. A stray // `h` tag must never channel-scope them (pinned contract — @@ -1160,6 +1167,284 @@ fn validate_team_catalog_envelope(event: &Event) -> Result<(), String> { Ok(()) } +/// Maximum number of member `a` tags on a kind:30621 project. +/// +/// Counted over raw tags, not distinct coordinates: a duplicate-heavy event +/// naming one coordinate thousands of times would otherwise be bounded only by +/// the relay frame limit (`config.rs`), so the cap must be checked before any +/// set proportional to the tag list is built. +const PROJECT_MEMBER_CAP: usize = 64; + +/// Maximum byte length of a project `name` tag value. +const PROJECT_NAME_MAX_LEN: usize = 256; + +/// Maximum byte length of a project `description` tag value. +const PROJECT_DESCRIPTION_MAX_LEN: usize = 2048; + +/// Maximum byte length of `buzz-channel` and `buzz-visibility` tag values. +/// +/// Both are opaque strings at the relay layer; the bound exists only so an +/// unbounded value cannot ride into storage on a tag ingest does not interpret. +const PROJECT_METADATA_TAG_MAX_LEN: usize = 256; + +/// Metadata tags a project may carry at most once each. +/// +/// Duplicates would make the effective value reader-dependent — one client +/// taking the first, another the last. +const PROJECT_SINGLETON_METADATA_TAGS: [&str; 4] = + ["name", "description", "buzz-channel", "buzz-visibility"]; + +/// The kind segment every project member coordinate must carry: a project groups +/// repository *announcements*, so a coordinate naming any other kind (notably +/// kind:30618 repository state) is malformed. +const PROJECT_MEMBER_KIND_SEGMENT: &str = "30617"; +const _: () = assert!(KIND_GIT_REPO_ANNOUNCEMENT == 30617); + +/// A validation failure from [`validate_project_envelope`] or +/// [`parse_project_member_coordinate`]. +/// +/// Carries the stable NIP-MP rule identifier alongside the human-readable +/// rejection message. The rule ID allows the fixture oracle and any future +/// cross-implementation conformance test to assert *which* rule fired, not just +/// that rejection occurred — an implementation cannot pass a reject fixture by +/// refusing for an unrelated reason. +/// +/// The eight IDs match the `reject_rules` strings in `NIP-MP.fixtures.json` +/// exactly: `d-cardinality`, `d-empty`, `member-cap`, `member-tag-arity`, +/// `member-coordinate-malformed`, `member-duplicate`, `metadata-cardinality`, +/// `metadata-length`. +#[derive(Debug)] +struct ProjectRejection { + /// Stable rule identifier matching the fixture file's `reject_rules` set. + rule: &'static str, + /// Human-readable explanation forwarded to the client's NOTICE/OK message. + message: String, +} + +impl std::fmt::Display for ProjectRejection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "[{}] {}", self.rule, self.message) + } +} + +impl ProjectRejection { + fn new(rule: &'static str, message: impl Into) -> Self { + Self { + rule, + message: message.into(), + } + } +} + +/// Validate the envelope of a kind:30621 NIP-MP project event. +/// +/// Enforces the structural contract in `docs/nips/NIP-MP.md` — exactly one +/// non-empty `d` tag, at most [`PROJECT_MEMBER_CAP`] member `a` tags each +/// holding a canonical `30617::` +/// coordinate with no duplicates, and bounded metadata. +/// +/// Deliberately absent: any membership authorization. The signer may reference +/// any repository coordinate, including another owner's, because membership +/// grants nothing — push policy reads the repository's own kind:30617 +/// (`api/git/policy.rs`) and never a project. Owner-only replacement comes free +/// from NIP-33 addressing. +/// +/// Duplicates are rejected rather than deduped: a relay cannot rewrite tags +/// inside a signed event without invalidating its id and signature, so the +/// choice is reject or force every consumer to apply a first-wins rule. +fn validate_project_envelope(event: &Event) -> Result<(), ProjectRejection> { + let mut d_tags: Vec<&str> = Vec::new(); + let mut members: Vec<&str> = Vec::new(); + let mut name: Option<&str> = None; + let mut description: Option<&str> = None; + let mut buzz_channel: Option<&str> = None; + let mut buzz_visibility: Option<&str> = None; + let mut singleton_counts = [0usize; PROJECT_SINGLETON_METADATA_TAGS.len()]; + + for tag in event.tags.iter() { + let parts = tag.as_slice(); + let Some(tag_name) = parts.first().map(|s| s.as_str()) else { + continue; + }; + let value = parts.get(1).map(|s| s.as_str()).unwrap_or(""); + match tag_name { + "d" => d_tags.push(value), + "a" => members.push(value), + _ => { + if let Some(i) = PROJECT_SINGLETON_METADATA_TAGS + .iter() + .position(|k| *k == tag_name) + { + singleton_counts[i] += 1; + match tag_name { + "name" => name = Some(value), + "description" => description = Some(value), + "buzz-channel" => buzz_channel = Some(value), + "buzz-visibility" => buzz_visibility = Some(value), + _ => {} + } + } + } + } + } + + // `d-cardinality` / `d-empty`: under NIP-33 a missing `d` is treated as + // empty, which collapses every such project into the `(pubkey, 30621, "")` + // slot where unrelated projects silently overwrite each other. Several `d` + // tags make the address reader-dependent. Length is bounded by the generic + // `D_TAG_MAX_LEN` check the ingest pipeline already applies. + if d_tags.len() != 1 { + return Err(ProjectRejection::new( + "d-cardinality", + format!( + "project event must have exactly one `d` tag (got {})", + d_tags.len() + ), + )); + } + if d_tags[0].is_empty() { + return Err(ProjectRejection::new( + "d-empty", + "project event `d` tag must not be empty", + )); + } + + // `member-cap` before `member-coordinate-malformed` and `member-duplicate`: + // refuse on count before doing per-tag work. + if members.len() > PROJECT_MEMBER_CAP { + return Err(ProjectRejection::new( + "member-cap", + format!( + "project event must have at most {PROJECT_MEMBER_CAP} member `a` tags (got {})", + members.len() + ), + )); + } + // `member-tag-arity`: every member `a` tag has exactly 2 or 3 elements per + // NIP-01's `a` tag grammar. A one-element tag names no coordinate; a fourth + // element has no defined meaning, and accepting it would let a writer park + // unbounded unvalidated data in a position no consumer reads. + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("a") && !(2..=3).contains(&parts.len()) { + return Err(ProjectRejection::new( + "member-tag-arity", + format!( + "project event member `a` tag must have exactly 2 or 3 elements (got {})", + parts.len() + ), + )); + } + } + let mut seen = std::collections::HashSet::with_capacity(members.len()); + for member in &members { + parse_project_member_coordinate(member)?; + if !seen.insert(*member) { + return Err(ProjectRejection::new( + "member-duplicate", + format!("project event has duplicate member coordinate {member:?}"), + )); + } + } + + for (i, count) in singleton_counts.iter().enumerate() { + if *count > 1 { + return Err(ProjectRejection::new( + "metadata-cardinality", + format!( + "project event must have at most one `{}` tag (got {count})", + PROJECT_SINGLETON_METADATA_TAGS[i] + ), + )); + } + } + if let Some(name) = name { + if name.len() > PROJECT_NAME_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `name` tag too long ({} bytes, max {PROJECT_NAME_MAX_LEN})", + name.len() + ), + )); + } + } + if let Some(description) = description { + if description.len() > PROJECT_DESCRIPTION_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `description` tag too long ({} bytes, max {PROJECT_DESCRIPTION_MAX_LEN})", + description.len() + ), + )); + } + } + if let Some(buzz_channel) = buzz_channel { + if buzz_channel.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-channel` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_channel.len() + ), + )); + } + } + if let Some(buzz_visibility) = buzz_visibility { + if buzz_visibility.len() > PROJECT_METADATA_TAG_MAX_LEN { + return Err(ProjectRejection::new( + "metadata-length", + format!( + "project event `buzz-visibility` tag too long ({} bytes, max {PROJECT_METADATA_TAG_MAX_LEN})", + buzz_visibility.len() + ), + )); + } + } + Ok(()) +} + +/// Check that `coordinate` is a canonical repository-announcement address. +/// +/// Splits on the first two colons only, matching how NIP-09 deletion handling +/// parses coordinates (`side_effects.rs`), so a repository whose `d` tag +/// contains a colon stays addressable and a project can never disagree with a +/// deletion about where the `d` value begins. +fn parse_project_member_coordinate(coordinate: &str) -> Result<(), ProjectRejection> { + let malformed = || { + ProjectRejection::new( + "member-coordinate-malformed", + format!( + "project event member `a` tag must be \ + `{PROJECT_MEMBER_KIND_SEGMENT}::` (got {coordinate:?})" + ), + ) + }; + let mut segments = coordinate.splitn(3, ':'); + let (Some(kind), Some(owner), Some(repo_d)) = + (segments.next(), segments.next(), segments.next()) + else { + return Err(malformed()); + }; + if kind != PROJECT_MEMBER_KIND_SEGMENT { + return Err(malformed()); + } + // Lowercase-only: `#a` filter matching is byte-exact, so an uppercase-owner + // head would be invisible to the lowercase-coordinate queries readers issue. + if owner.len() != 64 + || !owner + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(malformed()); + } + if repo_d.is_empty() { + return Err(malformed()); + } + Ok(()) +} + /// Validate that `content` is a syntactically plausible NIP-44 v2 ciphertext. /// /// Checks: @@ -2130,6 +2415,11 @@ async fn ingest_event_inner( .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; } + if kind_u32 == KIND_PROJECT { + validate_project_envelope(&event) + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + } + // Track pre-created channel UUID for compensation on insert failure. let mut pre_created_channel: Option = None; @@ -3946,6 +4236,407 @@ mod tests { assert!(!requires_h_channel_scope(KIND_TEAM_CATALOG)); } + // ─── project (NIP-MP kind:30621) envelope tests ────────────────────────── + + const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + fn make_project(tags: &[&[&str]]) -> Event { + make_event_with_tags(KIND_PROJECT, "", tags) + } + + fn member_coord(owner: &str, repo_d: &str) -> String { + format!("30617:{owner}:{repo_d}") + } + + #[test] + fn project_envelope_accepts_minimal() { + let ev = make_project(&[&["d", "platform"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_full_cross_owner_membership() { + // The motivating case: one project spanning two owners' repositories. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz-infra"); + let ev = make_project(&[ + &["d", "platform"], + &["name", "Platform"], + &["description", "Relay, desktop, and mobile."], + &["a", &a], + &["a", &b], + &["buzz-channel", "3580ca9b-47b4-4af9-b22a-1068778f26c6"], + &["buzz-visibility", "listed"], + ]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_zero_members() { + // Legal at the protocol layer: the natural state after removing a final + // member. The create UI requires >= 1; the relay must not. + let ev = make_project(&[&["d", "empty"], &["name", "Empty"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_same_repo_d_under_two_owners() { + // The NIP-34 fork case. Identity is the whole coordinate, so these are + // two distinct members, not a duplicate. + let a = member_coord(OWNER_A, "buzz"); + let b = member_coord(OWNER_B, "buzz"); + let ev = make_project(&[&["d", "forks"], &["a", &a], &["a", &b]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_repo_d_containing_colon() { + // Coordinates split on the first two colons only, matching NIP-09 + // deletion parsing, so a colon-bearing repository `d` stays addressable. + let coord = member_coord(OWNER_A, "group:repo"); + let ev = make_project(&[&["d", "external"], &["a", &coord]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_accepts_member_cap_boundary() { + let coords: Vec = (0..PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + assert!( + validate_project_envelope(&ev).is_ok(), + "exactly {PROJECT_MEMBER_CAP} members must be accepted" + ); + } + + #[test] + fn project_envelope_ignores_unknown_tags() { + // Forward compatibility: a newer writer's extra metadata must not + // invalidate the event for this relay. + let ev = make_project(&[&["d", "platform"], &["future-field", "whatever"]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_missing_d_tag() { + let ev = make_project(&[&["name", "No Identity"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_multiple_d_tags() { + let ev = make_project(&[&["d", "one"], &["d", "two"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly one `d` tag"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_empty_d_tag() { + // An empty `d` collapses every such project into the (pubkey, 30621, "") + // slot, where unrelated projects silently overwrite each other. + let ev = make_project(&[&["d", ""]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_valueless_d_tag() { + // `["d"]` with no value is treated as empty, not as absent. + let ev = make_project(&[&["d"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("must not be empty"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_member_coordinate() { + let coord = member_coord(OWNER_A, "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("duplicate member coordinate"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_cap_exceeded() { + let coords: Vec = (0..=PROJECT_MEMBER_CAP) + .map(|i| member_coord(OWNER_A, &format!("repo-{i}"))) + .collect(); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + tags.extend(coords.iter().map(|c| vec!["a", c.as_str()])); + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!(err.to_string().contains("at most 64 member"), "got: {err}"); + } + + #[test] + fn project_envelope_rejects_duplicate_heavy_list_on_cap_not_duplicate() { + // The cap counts raw `a` tags, so a duplicate-heavy list is refused on + // count — parse volume is never bounded only by the frame limit. + let coord = member_coord(OWNER_A, "buzz"); + let mut tags: Vec> = vec![vec!["d", "wide"]]; + for _ in 0..=PROJECT_MEMBER_CAP { + tags.push(vec!["a", coord.as_str()]); + } + let tag_refs: Vec<&[&str]> = tags.iter().map(|t| t.as_slice()).collect(); + let ev = make_project(&tag_refs); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("at most 64 member"), + "cap must be evaluated before the duplicate set is built, got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_wrong_kind_prefix() { + // kind:30618 is repository *state*; a project groups announcements. + let coord = format!("30618:{OWNER_A}:buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_not_hex() { + let coord = member_coord(&"z".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_uppercase_hex() { + // `#a` filter matching is byte-exact: an uppercase-owner head would be + // invisible to the lowercase-coordinate queries every reader issues. + let coord = member_coord(&"A".repeat(64), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_owner_wrong_length() { + let coord = member_coord(&"a".repeat(63), "buzz"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_empty_repo_d() { + let coord = member_coord(OWNER_A, ""); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_member_missing_segment() { + let coord = format!("30617:{OWNER_A}"); + let ev = make_project(&[&["d", "platform"], &["a", &coord]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("member `a` tag must be"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_valueless_member_tag() { + // A one-element `a` tag names no coordinate — caught by the arity check + // (rule 4) before the coordinate parse (rule 5) even runs. + let ev = make_project(&[&["d", "platform"], &["a"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("exactly 2 or 3 elements"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_rejects_duplicate_metadata_tags() { + // Every singleton metadata tag is bounded: a duplicate would make the + // effective value reader-dependent. + for tag_name in PROJECT_SINGLETON_METADATA_TAGS { + let ev = make_project(&[&["d", "platform"], &[tag_name, "x"], &[tag_name, "y"]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string() + .contains(&format!("at most one `{tag_name}` tag")), + "duplicate `{tag_name}` must be rejected, got: {err}" + ); + } + } + + #[test] + fn project_envelope_rejects_name_too_long() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`name` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_name_at_max_length() { + let name = "x".repeat(PROJECT_NAME_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["name", &name]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_envelope_rejects_description_too_long() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN + 1); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + let err = validate_project_envelope(&ev).unwrap_err(); + assert!( + err.to_string().contains("`description` tag too long"), + "got: {err}" + ); + } + + #[test] + fn project_envelope_accepts_description_at_max_length() { + let description = "x".repeat(PROJECT_DESCRIPTION_MAX_LEN); + let ev = make_project(&[&["d", "platform"], &["description", &description]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + /// Membership is an assertion, not a permission grant: the relay must accept + /// a project naming a repository the signer does not own. Cross-owner + /// grouping is the entire point of the kind, and it is safe precisely because + /// membership confers nothing. + #[test] + fn project_envelope_accepts_member_owned_by_another_pubkey() { + let stranger = member_coord(OWNER_B, "not-mine"); + let ev = make_project(&[&["d", "collection"], &["a", &stranger]]); + assert!(validate_project_envelope(&ev).is_ok()); + } + + #[test] + fn project_is_in_scope_allowlist() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_PROJECT, &dummy).unwrap(), + Scope::ReposWrite, + "a project is repository metadata — same scope as announcing a repo" + ); + } + + #[test] + fn project_is_global_only() { + // `buzz-channel` is a metadata reference, not a routing directive. + assert!(is_global_only_kind(KIND_PROJECT)); + assert!(!requires_h_channel_scope(KIND_PROJECT)); + } + + #[test] + fn project_is_parameterized_replaceable() { + // Owner-only editing comes free from NIP-33 addressing: replacement is + // keyed by (pubkey, kind, d), so one signer can never overwrite another's + // project. No relay-side permission check exists or is needed. + assert!(is_parameterized_replaceable(KIND_PROJECT)); + } + + /// Drive every case in the shared NIP-MP fixture file against + /// `validate_project_envelope`. All 11 accept cases must pass; all 20 + /// reject cases must return an error whose rule is in the case's allowed + /// `reject_rules` set — an implementation cannot pass by rejecting for an + /// unrelated reason. This is the machine-readable oracle the spec promises. + #[test] + fn project_envelope_validates_all_shared_fixtures() { + #[derive(serde::Deserialize)] + struct FixtureFile { + cases: Vec, + } + #[derive(serde::Deserialize)] + struct Case { + name: String, + expect: String, + #[serde(default)] + reject_rules: Vec, + template: Template, + } + #[derive(serde::Deserialize)] + struct Template { + content: String, + tags: Vec>, + } + + let raw = include_str!("../../../../docs/nips/NIP-MP.fixtures.json"); + let file: FixtureFile = serde_json::from_str(raw).expect("fixture file must parse"); + + for case in &file.cases { + let tag_strs: Vec> = case + .template + .tags + .iter() + .map(|t| t.iter().map(|s| s.as_str()).collect()) + .collect(); + let tag_refs: Vec<&[&str]> = tag_strs.iter().map(|t| t.as_slice()).collect(); + let ev = make_event_with_tags(KIND_PROJECT, &case.template.content, &tag_refs); + let result = validate_project_envelope(&ev); + match case.expect.as_str() { + "accept" => assert!( + result.is_ok(), + "fixture {:?} expected accept, got err: {:?}", + case.name, + result.unwrap_err() + ), + "reject" => { + let rejection = match result { + Err(r) => r, + Ok(()) => { + panic!("fixture {:?} expected reject, but was accepted", case.name) + } + }; + assert!( + case.reject_rules.iter().any(|r| r == rejection.rule), + "fixture {:?} fired rule {:?}, which is not in allowed set {:?}", + case.name, + rejection.rule, + case.reject_rules, + ); + } + other => panic!( + "unknown expect value {:?} in fixture {:?}", + other, case.name + ), + } + } + } + // ─── agent_turn_metric envelope tests ──────────────────────────────────── /// Build an event for kind:44200 with the given tags and content. diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 65d04ef0ba..660a55fef3 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -2167,9 +2167,18 @@ async fn handle_a_tag_deletion( }; // Safe cast: NIP-33 kinds are 30000–39999, well within i32. let kind_i32 = k as i32; + // NIP-09 scopes an a-tag deletion to versions at or before the + // deletion's own created_at, so a stale/replayed tombstone can never + // erase a newer replacement head. let deleted = state .db - .soft_delete_by_coordinate(tenant.community(), kind_i32, &pubkey_bytes, d_tag) + .soft_delete_by_coordinate( + tenant.community(), + kind_i32, + &pubkey_bytes, + d_tag, + event.created_at.as_secs() as i64, + ) .await .map_err(|e| { anyhow::anyhow!( diff --git a/crates/buzz-test-client/tests/e2e_project.rs b/crates/buzz-test-client/tests/e2e_project.rs new file mode 100644 index 0000000000..c0a05e4674 --- /dev/null +++ b/crates/buzz-test-client/tests/e2e_project.rs @@ -0,0 +1,491 @@ +//! End-to-end tests for kind:30621 multi-repo project events (NIP-MP). +//! +//! The ingest unit tests in `buzz-relay` pin the envelope contract in isolation. +//! These tests cover the three behaviors that only exist once an event reaches +//! storage, plus proof that the envelope validator is actually wired into the +//! live write path: +//! - a valid cross-owner project round-trips through its NIP-33 coordinate; +//! - replacement is keyed by `(pubkey, 30621, d)` — newer wins for one author, +//! and two authors sharing a `d` hold two independent projects (this is what +//! makes owner-only editing free rather than a relay permission check); +//! - a NIP-09 `a`-tag tombstone removes the project coordinate and leaves every +//! referenced kind:30617 announcement untouched, because membership is an +//! assertion about repositories and never authority over them; +//! - malformed envelopes are refused by the relay, not merely by the validator. +//! +//! See `docs/nips/NIP-MP.md` for the normative contract. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! RELAY_URL=ws://localhost:3000 cargo test -p buzz-test-client --test e2e_project -- --ignored +//! ``` + +use std::time::Duration; + +use buzz_test_client::BuzzTestClient; +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; + +const PROJECT_KIND: u16 = 30621; +const REPO_ANNOUNCEMENT_KIND: u16 = 30617; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-project-{name}-{}", uuid::Uuid::new_v4()) +} + +/// A short unique suffix so concurrent runs never collide on a `d` tag. +fn unique(prefix: &str) -> String { + format!("{prefix}-{}", &uuid::Uuid::new_v4().to_string()[..8]) +} + +fn member_coord(owner: &Keys, repo_d: &str) -> String { + format!( + "{REPO_ANNOUNCEMENT_KIND}:{}:{repo_d}", + owner.public_key().to_hex() + ) +} + +/// Build a project event. `members` are canonical `30617::` +/// coordinates; `created_at` defaults to now when `None`. +fn project_event( + keys: &Keys, + d_tag: &str, + name: &str, + members: &[String], + created_at: Option, +) -> nostr::Event { + let mut tags = vec![ + Tag::parse(["d", d_tag]).unwrap(), + Tag::parse(["name", name]).unwrap(), + ]; + tags.extend( + members + .iter() + .map(|m| Tag::parse(["a", m.as_str()]).unwrap()), + ); + let builder = EventBuilder::new(Kind::Custom(PROJECT_KIND), "").tags(tags); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +/// Announce a repository so a project has a real coordinate to reference. +fn repo_announcement(keys: &Keys, repo_d: &str) -> nostr::Event { + EventBuilder::new(Kind::Custom(REPO_ANNOUNCEMENT_KIND), "") + .tags(vec![ + Tag::parse(["d", repo_d]).unwrap(), + Tag::parse(["name", repo_d]).unwrap(), + ]) + .sign_with_keys(keys) + .unwrap() +} + +/// A NIP-09 `a`-tag-only deletion at a NIP-33 coordinate. No `e` tag, so the +/// relay takes the coordinate-delete path rather than the event-id path. +/// `created_at` defaults to now when `None`. +fn coordinate_delete(keys: &Keys, kind: u16, d_tag: &str, created_at: Option) -> nostr::Event { + let coord = format!("{kind}:{}:{d_tag}", keys.public_key().to_hex()); + let builder = + EventBuilder::new(Kind::Custom(5), "") + .tags(vec![Tag::parse(["a", coord.as_str()]).unwrap()]); + match created_at { + Some(ts) => builder.custom_created_at(Timestamp::from(ts)), + None => builder, + } + .sign_with_keys(keys) + .unwrap() +} + +fn addressable_filter(kind: u16, author: &Keys, d_tag: &str) -> Filter { + Filter::new() + .kind(Kind::Custom(kind)) + .author(author.public_key()) + .custom_tags(SingleLetterTag::lowercase(Alphabet::D), [d_tag]) +} + +/// Subscribe with `filter` and drain to EOSE. +async fn query(client: &mut BuzzTestClient, name: &str, filter: Filter) -> Vec { + let sid = sub_id(name); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect events") +} + +#[tokio::test] +#[ignore] +async fn test_project_publish_and_query_returns_cross_owner_members() { + let url = relay_url(); + let owner = Keys::generate(); + let other = Keys::generate(); + let d_tag = unique("project"); + + let members = vec![ + member_coord(&owner, "buzz"), + member_coord(&other, "buzz-infra"), + ]; + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let event = project_event(&owner, &d_tag, "Platform", &members, None); + let ok = client.send_event(event).await.expect("send project"); + assert!(ok.accepted, "relay rejected project event: {}", ok.message); + + let events = query( + &mut client, + "query", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!(events.len(), 1, "expected exactly one project event"); + let stored: Vec<&str> = events[0] + .tags + .iter() + .filter_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("a")).then(|| parts[1].as_str()) + }) + .collect(); + assert_eq!( + stored, members, + "both members must survive the round trip, including the one owned by another pubkey" + ); + + client.disconnect().await.expect("disconnect"); +} + +#[tokio::test] +#[ignore] +async fn test_project_replacement_keeps_only_newest_for_same_author_and_d() { + let url = relay_url(); + let owner = Keys::generate(); + let d_tag = unique("project-replace"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let first = project_event(&owner, &d_tag, "Old", &[], Some(now - 100)); + let ok = client.send_event(first).await.expect("send old"); + assert!(ok.accepted, "relay rejected old project: {}", ok.message); + + let members = vec![member_coord(&owner, "buzz")]; + let second = project_event(&owner, &d_tag, "New", &members, Some(now)); + let ok = client.send_event(second).await.expect("send new"); + assert!(ok.accepted, "relay rejected new project: {}", ok.message); + + let events = query( + &mut client, + "replace", + addressable_filter(PROJECT_KIND, &owner, &d_tag), + ) + .await; + + assert_eq!( + events.len(), + 1, + "NIP-33: only the newest head should remain" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, "New", "the newer head must win"); + + client.disconnect().await.expect("disconnect"); +} + +/// Owner-only editing is a property of the addressable model, not a relay +/// permission check: two authors publishing the same `d` occupy two coordinates, +/// so neither can overwrite the other. This is the test that would fail if the +/// kind were ever classified as plain-replaceable or keyed on `d` alone. +#[tokio::test] +#[ignore] +async fn test_project_same_d_under_two_authors_are_independent() { + let url = relay_url(); + let alice = Keys::generate(); + let bob = Keys::generate(); + let d_tag = unique("project-shared-d"); + + let mut alice_client = BuzzTestClient::connect(&url, &alice) + .await + .expect("connect"); + let ok = alice_client + .send_event(project_event(&alice, &d_tag, "Alice", &[], None)) + .await + .expect("send alice"); + assert!( + ok.accepted, + "relay rejected alice's project: {}", + ok.message + ); + + let mut bob_client = BuzzTestClient::connect(&url, &bob).await.expect("connect"); + let ok = bob_client + .send_event(project_event(&bob, &d_tag, "Bob", &[], None)) + .await + .expect("send bob"); + assert!(ok.accepted, "relay rejected bob's project: {}", ok.message); + + for (label, keys, expected_name) in [("alice", &alice, "Alice"), ("bob", &bob, "Bob")] { + let events = query( + &mut alice_client, + label, + addressable_filter(PROJECT_KIND, keys, &d_tag), + ) + .await; + assert_eq!( + events.len(), + 1, + "{label} should still hold their own project at the shared `d`" + ); + let name = events[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("name tag"); + assert_eq!(name, expected_name, "{label}'s project was overwritten"); + } + + alice_client.disconnect().await.expect("disconnect"); + bob_client.disconnect().await.expect("disconnect"); +} + +/// Deleting a project must delete only the grouping. A project is metadata about +/// repositories; if a tombstone at the project coordinate cascaded to the +/// referenced kind:30617s, adding a repo to someone's project would become a way +/// to destroy it. +#[tokio::test] +#[ignore] +async fn test_project_tombstone_deletes_coordinate_and_spares_members() { + let url = relay_url(); + let owner = Keys::generate(); + let repo_d = unique("repo"); + let project_d = unique("project-tombstone"); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(repo_announcement(&owner, &repo_d)) + .await + .expect("send announcement"); + assert!(ok.accepted, "relay rejected announcement: {}", ok.message); + + let members = vec![member_coord(&owner, &repo_d)]; + let ok = client + .send_event(project_event(&owner, &project_d, "Doomed", &members, None)) + .await + .expect("send project"); + assert!(ok.accepted, "relay rejected project: {}", ok.message); + + let before = query( + &mut client, + "tombstone-pre", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert_eq!(before.len(), 1, "project should be live before deletion"); + + let ok = client + .send_event(coordinate_delete(&owner, PROJECT_KIND, &project_d, None)) + .await + .expect("send tombstone"); + assert!(ok.accepted, "relay rejected tombstone: {}", ok.message); + + let after = query( + &mut client, + "tombstone-post", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + assert!( + after.is_empty(), + "tombstone should remove the project coordinate, got {} event(s)", + after.len() + ); + + let repo = query( + &mut client, + "member-after", + addressable_filter(REPO_ANNOUNCEMENT_KIND, &owner, &repo_d), + ) + .await; + assert_eq!( + repo.len(), + 1, + "deleting a project must not touch the repositories it referenced" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-09 scopes an `a`-tag deletion to versions at or before the deletion's own +/// `created_at`. A tombstone signed between V1 and V2 — delayed in transit or +/// replayed by a third party — must therefore retire V1 only and leave the newer +/// V2 head live. Before the timestamp predicate landed in +/// `soft_delete_by_coordinate`, the coordinate delete was timestamp-blind and +/// this sequence silently destroyed V2. +#[tokio::test] +#[ignore] +async fn test_stale_tombstone_between_versions_leaves_newer_project_live() { + let url = relay_url(); + let owner = Keys::generate(); + let project_d = unique("project-stale-tombstone"); + let now = Timestamp::now().as_secs(); + + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let ok = client + .send_event(project_event( + &owner, + &project_d, + "V1", + &[], + Some(now - 100), + )) + .await + .expect("send v1"); + assert!(ok.accepted, "relay rejected V1: {}", ok.message); + + let ok = client + .send_event(project_event(&owner, &project_d, "V2", &[], Some(now))) + .await + .expect("send v2"); + assert!(ok.accepted, "relay rejected V2: {}", ok.message); + + // Timestamped strictly between V1 and V2: valid for V1, stale for V2. + let ok = client + .send_event(coordinate_delete( + &owner, + PROJECT_KIND, + &project_d, + Some(now - 50), + )) + .await + .expect("send stale tombstone"); + assert!( + ok.accepted, + "a well-formed tombstone is still an acceptable event: {}", + ok.message + ); + + let after = query( + &mut client, + "stale-tombstone", + addressable_filter(PROJECT_KIND, &owner, &project_d), + ) + .await; + + assert_eq!( + after.len(), + 1, + "a tombstone older than the live head must not delete it, got {} event(s)", + after.len() + ); + let name = after[0] + .tags + .iter() + .find_map(|t| { + let parts = t.as_slice(); + (parts.first().map(|s| s.as_str()) == Some("name")).then(|| parts[1].as_str()) + }) + .expect("surviving head must carry its name tag"); + assert_eq!(name, "V2", "the surviving head must be the newer version"); + + client.disconnect().await.expect("disconnect"); +} + +/// Proves the envelope validator is reachable from the live write path — a unit +/// test of `validate_project_envelope` cannot show that ingest calls it. +#[tokio::test] +#[ignore] +async fn test_project_malformed_envelope_rejected_by_relay() { + let url = relay_url(); + let owner = Keys::generate(); + let mut client = BuzzTestClient::connect(&url, &owner) + .await + .expect("connect"); + + let duplicate = member_coord(&owner, "buzz"); + // Each case pairs a malformed event with the substring its rejection must + // carry, so a refusal for an unrelated reason cannot satisfy the assertion. + let cases: Vec<(&str, nostr::Event, &str)> = vec![ + ( + "duplicate member coordinate", + project_event( + &owner, + &unique("project-dup"), + "Dup", + &[duplicate.clone(), duplicate], + None, + ), + "duplicate member coordinate", + ), + ( + "member coordinate naming the wrong kind", + project_event( + &owner, + &unique("project-badkind"), + "Bad kind", + &[format!("30618:{}:buzz", owner.public_key().to_hex())], + None, + ), + "member `a` tag must be", + ), + ( + "member coordinate with an uppercase-hex owner", + project_event( + &owner, + &unique("project-upper"), + "Uppercase", + &[format!("{REPO_ANNOUNCEMENT_KIND}:{}:buzz", "A".repeat(64))], + None, + ), + "member `a` tag must be", + ), + ]; + + for (label, event, expected) in cases { + let ok = client.send_event(event).await.expect("send"); + assert!( + !ok.accepted, + "relay must reject a project with a {label}, got OK: {}", + ok.message + ); + assert!( + ok.message.contains(expected), + "rejection for {label} must name the rule that fired, got: {}", + ok.message + ); + } + + client.disconnect().await.expect("disconnect"); +} diff --git a/docs/nips/NIP-MP.md b/docs/nips/NIP-MP.md index 6ca1ec4749..14936d2a7c 100644 --- a/docs/nips/NIP-MP.md +++ b/docs/nips/NIP-MP.md @@ -181,7 +181,7 @@ A relay accepting `kind:30621` MUST validate the envelope at ingest. The rule na Rules 3 through 6 are evaluated in that order, so an oversized tag list is refused on count before any per-tag parse or set proportional to it is built. -Three checks land in the Buzz validator together with the fixture wiring that exercises them: the `buzz-channel` and `buzz-visibility` bounds in rule 8, and rule 4's arity. The validator bounds `name` and `description` today, and reads element 1 of each member `a` tag while ignoring any element past it. +The Buzz validator enforces all eight rules. The shared fixtures in [`NIP-MP.fixtures.json`](NIP-MP.fixtures.json) are wired as its test oracle: the relay's unit test suite runs every case against `validate_project_envelope` and asserts each `expect` outcome. **Duplicates are rejected, never normalized.** A relay cannot dedupe tags inside a signed event: rewriting the tag array changes the event id and invalidates the signature. The choices are reject, or accept and require every present and future consumer to apply a first-wins interpretation rule. Rejecting keeps every stored head canonical and spares all consumers a defensive parse. @@ -297,7 +297,7 @@ Legacy `:` repository routes remain valid and resolve to that repos ## Conformance Fixtures -Two fixture files carry the machine-checkable contract. Neither has consumers yet; each states what its consumers are required to do. +Two fixture files carry the machine-checkable contract. `NIP-MP.fixtures.json` is already wired as the relay ingest consumer; the remaining consumers listed below are Phase 2 work. ### Ingest From b1b283cd4c7f926e12eeee8ae1f38c7471922b16 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Fri, 31 Jul 2026 16:41:56 -0400 Subject: [PATCH 49/87] fix(buzz-acp): thread cache-read tokens into NIP-AM kind:44200 events (#3999) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem `buzz-agent` measures and sends `accumulatedCachedInputTokens` on the wire (`usage.rs:93`). `buzz-acp` deserializes it correctly — but then drops it: `TurnUsage` had no cache field, and `build_turn_metric_counts` hardcoded `cache_read_tokens: None` and `cache_write_tokens: None` into both `turn` and `cumulative` `TokenCounts`. Every kind:44200 event published permanently lacked data the harness measured. The archive is append-only — this is unrecoverable data loss per turn, every turn, until fixed. NIP-AM already specifies the fields (`cacheReadTokens` / `cacheWriteTokens` inside `turn` and `cumulative`). This is a pure threading fix. ## Changes **`crates/buzz-acp/src/usage.rs`** - `SessionState` gains `last_cached_input: u64` to track the committed cache-read baseline. - `TurnUsage` gains `turn_cache_read_tokens: Option` (field-local; `None` when no baseline or counter decreased) and `cumulative_cache_read_tokens: u64` (always present; zero when no cache hits reported). - `record()` computes the cache-read delta with field-local taint semantics: a decrease in the cumulative counter nulls only `turn_cache_read_tokens` — it does not flip `delta_reliable` or invalidate `turn_input_tokens`/`turn_output_tokens`. Identical to the `accumulatedTotalTokens` pattern already present. - `take()` and the setup-notification branch both advance `last_cached_input` in the committed baseline. **`crates/buzz-acp/src/pool.rs`** - `build_turn_metric_counts` wires `turn_cache_read_tokens` into `turn.cache_read_tokens` (when `delta_reliable`) and `Some(cumulative_cache_read_tokens)` into `cumulative.cache_read_tokens`. - `cache_write_tokens` remains `None` on both counts with an explanatory comment: buzz-agent does not emit a write-side count on the wire today. - Six existing `TurnUsage` struct literals in tests updated with the two new fields. ## Tests **`usage.rs` — new cache-read section (5 tests):** - `cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through` — no baseline → delta None, cumulative passes through - `cache_read_second_turn_delta_computed_correctly` — delta = current − previous - `cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable` — field-local taint: decrease nulls cache delta only, input/output stay reliable - `cache_read_zero_payload_after_baseline_produces_zero_delta` — zero on both sides → `Some(0)`, not `None` - `cache_read_threads_through_setup_notification_baseline` — setup notification baseline correctly seeds the cache counter **`pool.rs` — new acceptance test (1 test):** - `test_build_turn_metric_counts_cache_read_tokens_thread_through` — wire-parses a buzz-agent payload with nonzero `accumulatedCachedInputTokens`, runs two turns through the tracker and `build_turn_metric_counts`, and asserts nonzero `cacheReadTokens` in cumulative + correct per-turn delta in `turn`; also asserts `cache_write_tokens` is `None` throughout ## Quality gates at tip `c6405eb43f532572e3b7775e0dee826dc9cb3f82` | Gate | Result | |---|---| | `cargo test -p buzz-acp` | **655/655**, 0 failed | | `cargo clippy -p buzz-acp --all-targets -- -D warnings` | clean | | `cargo fmt --check` | clean | Note: the pre-push hook `mobile-test` gate fails on `origin/main` before this branch (Flutter test in `channels_page_test.dart` / `compose_bar_test.dart` — verified independently). My changes touch only `crates/buzz-acp/src/`; the mobile failure is unrelated and pre-existing. --------- Signed-off-by: Will Pfleger Co-authored-by: npub1g8493u0xfsjrvflg4n08ezd7vec99mnwzlv0qgwpr9d7gvjwhuzqx59rhw <41ea58f1e64c243627e8acde7c89be667052ee6e17d8f021c1195be4324ebf04@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/pool.rs | 116 +++++++++- crates/buzz-acp/src/usage.rs | 415 +++++++++++++++++++++++++++++++++-- 2 files changed, 511 insertions(+), 20 deletions(-) diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 158477c0af..348bc138e4 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -3624,7 +3624,11 @@ pub(crate) fn build_turn_metric_counts( // from input+output. total_tokens: usage.turn_total_tokens, cost_usd: usage.turn_cost_usd, - cache_read_tokens: None, + // Field-local: present when the cumulative counter was monotonic + // across this turn. Zero means no cache hits this turn (not absent). + cache_read_tokens: usage.turn_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }) } else { @@ -3642,7 +3646,13 @@ pub(crate) fn build_turn_metric_counts( // one. Never derived from input+output (NIP-AM MUST NOT). total_tokens: usage.cumulative_total_tokens, cost_usd: usage.cumulative_cost_usd, - cache_read_tokens: None, + // Session-cumulative cache-read tokens; None when the harness never + // reported this field (e.g. goose or older buzz-agent sessions). + // Passes through directly — do not wrap in Some() as the field already + // carries provenance (None vs Some(0) are distinct meanings). + cache_read_tokens: usage.cumulative_cache_read_tokens, + // buzz-agent does not emit a cache-write count on the wire today; + // leave None rather than deriving it from other fields. cache_write_tokens: None, }); (turn_counts, cumulative_counts) @@ -6022,10 +6032,12 @@ mod tests { turn_output_tokens: Some(50), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 100, cumulative_output_tokens: 50, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // owner_pubkey = None → early return, no panic. @@ -6056,10 +6068,12 @@ mod tests { turn_output_tokens: Some(80), turn_total_tokens: None, turn_cost_usd: Some(0.001), + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 80, cumulative_total_tokens: None, cumulative_cost_usd: Some(0.001), + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish and fail (no real relay) but must not panic. @@ -6091,10 +6105,12 @@ mod tests { turn_output_tokens: Some(20), turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 150, cumulative_output_tokens: 70, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Must not panic; HTTP submit will fail (no real relay) — that's fine. @@ -6126,10 +6142,12 @@ mod tests { turn_output_tokens: None, turn_total_tokens: None, turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 400, cumulative_output_tokens: 100, cumulative_total_tokens: None, cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; // Will try to publish (encrypt succeeds) and fail HTTP (no relay) — must not panic. @@ -6158,10 +6176,12 @@ mod tests { turn_output_tokens: Some(30), turn_total_tokens: Some(130), // genuine per-turn total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 500, cumulative_output_tokens: 120, cumulative_total_tokens: Some(620), // genuine cumulative total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6205,10 +6225,12 @@ mod tests { turn_output_tokens: Some(60), turn_total_tokens: None, // provider did not supply a total turn_cost_usd: None, + turn_cache_read_tokens: None, cumulative_input_tokens: 200, cumulative_output_tokens: 60, cumulative_total_tokens: None, // session has no total cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, model: None, }; @@ -6248,6 +6270,96 @@ mod tests { ); } + /// A payload with nonzero `accumulatedCachedInputTokens` on the second turn + /// must produce a kind:44200 payload where `cumulative.cacheReadTokens` is + /// nonzero and `turn.cacheReadTokens` reflects the per-turn delta. + /// This is the acceptance-criterion test: it proves the threading is live, + /// not hardcoded to None. + #[test] + fn test_build_turn_metric_counts_cache_read_tokens_thread_through() { + // Wire-parse a buzz-agent payload with cache, run it through the tracker, + // and verify the published TokenCounts carry the cache field. + let raw1 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 15_091, + "accumulatedOutputTokens": 156, + "accumulatedCachedInputTokens": 5_033, + } + }); + let raw2 = serde_json::json!({ + "sessionId": "cache-sess", + "update": { + "sessionUpdate": "usage_update", + "accumulatedInputTokens": 28_500, + "accumulatedOutputTokens": 310, + "accumulatedCachedInputTokens": 11_000, + } + }); + + let mut tracker = crate::usage::UsageTracker::default(); + + // Turn 1 — establish baseline (delta unreliable, but cumulative still present). + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw1) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t1 = tracker.take().expect("turn 1"); + + // Turn 1: cumulative must carry the cache count; turn delta is None (no baseline). + let (turn1, cum1) = crate::pool::build_turn_metric_counts(&t1); + // delta_reliable = false on first turn → no turn counts. + assert!(turn1.is_none(), "first turn: no reliable turn counts"); + let cum1 = cum1.expect("cumulative always present"); + assert_eq!( + cum1.cache_read_tokens, + Some(5_033), + "cumulative.cacheReadTokens must be 5033 after turn 1" + ); + + // Turn 2 — delta reliable. + tracker.begin_turn("cache-sess"); + if let crate::usage::GooseSessionUpdateVariant::UsageUpdate(p) = + serde_json::from_value::(raw2) + .unwrap() + .update + { + tracker.record("cache-sess", &p); + } + let t2 = tracker.take().expect("turn 2"); + + let (turn2, cum2) = crate::pool::build_turn_metric_counts(&t2); + + let turn2 = turn2.expect("reliable turn counts on turn 2"); + // Per-turn cache delta: 11_000 - 5_033 = 5_967. + assert_eq!( + turn2.cache_read_tokens, + Some(5_967), + "turn.cacheReadTokens must be the per-turn delta" + ); + // cache_write_tokens is always None — buzz-agent doesn't emit it. + assert!( + turn2.cache_write_tokens.is_none(), + "cache_write_tokens must be None — not emitted by buzz-agent" + ); + + let cum2 = cum2.expect("cumulative always present"); + assert_eq!( + cum2.cache_read_tokens, + Some(11_000), + "cumulative.cacheReadTokens must be 11_000 after turn 2" + ); + assert!( + cum2.cache_write_tokens.is_none(), + "cache_write_tokens must be None on cumulative too" + ); + } + fn make_prompt_context_no_owner() -> PromptContext { let agent_keys = nostr::Keys::generate(); make_prompt_context_impl(&agent_keys, None) diff --git a/crates/buzz-acp/src/usage.rs b/crates/buzz-acp/src/usage.rs index 1629eee935..56b772d12c 100644 --- a/crates/buzz-acp/src/usage.rs +++ b/crates/buzz-acp/src/usage.rs @@ -85,12 +85,16 @@ pub(crate) struct UsageUpdatePayload { pub context_limit: u64, pub accumulated_input_tokens: u64, pub accumulated_output_tokens: u64, - /// The cache-served subset of `accumulated_input_tokens`. Optional — goose - /// does not send it, and buzz-agent only reports a non-zero value when the - /// provider returned a cache split, so `0` legitimately means either "no - /// cache hits" or "provider reported none". - #[serde(default)] - pub accumulated_cached_input_tokens: u64, + /// The cache-served subset of `accumulated_input_tokens`. + /// + /// `None` when the harness did not include the field (e.g. goose, which + /// never emits it). `Some(0)` when the harness explicitly reported zero + /// cache hits. The distinction matters: `None` means "we don't know", + /// while `Some(0)` means "provider confirmed no cache was used". + /// + /// Do NOT use `#[serde(default)]` here — that would collapse the absent + /// case into `Some(0)` and destroy provenance in the append-only archive. + pub accumulated_cached_input_tokens: Option, pub accumulated_cost: Option, /// Session-cumulative genuine provider total tokens. Optional — only /// emitted by buzz-agent when every turn in the session so far supplied a @@ -125,6 +129,12 @@ struct SessionState { /// `None` when the session has never emitted a provider total (Unseen) or /// when any prior turn lacked one (poisoned). last_total: Option, + /// Cumulative cache-read input tokens at the end of the LAST PUBLISHED turn. + /// `None` when the harness has never reported this field (e.g. goose). + /// `Some(n)` when at least one payload included the field. Field-local: + /// a decrease in this counter taints only the cache-read delta, not + /// `delta_reliable` or the input/output deltas. + last_cached_input: Option, } /// Per-turn usage record exposed to `TurnCompletionGuard` for NIP-AM publishing. @@ -151,6 +161,12 @@ pub struct TurnUsage { /// Per-turn cost delta (`current − previous`); `None` when unreliable or /// either snapshot is missing. pub turn_cost_usd: Option, + /// Per-turn cache-read token delta (`current − previous`); `None` when no + /// baseline exists, either snapshot is `None` (harness did not report it), + /// or the cumulative counter decreased (field-local taint). Field-local: + /// a decrease here never flips `delta_reliable` or invalidates the + /// input/output deltas. + pub turn_cache_read_tokens: Option, /// Session-cumulative input tokens as reported by goose at end of turn. pub cumulative_input_tokens: u64, /// Session-cumulative output tokens as reported by goose at end of turn. @@ -160,6 +176,11 @@ pub struct TurnUsage { pub cumulative_total_tokens: Option, /// Session-cumulative estimated cost in USD; `None` if goose did not report it. pub cumulative_cost_usd: Option, + /// Session-cumulative cache-read input tokens as reported by buzz-agent. + /// `None` when the harness has never reported this field (e.g. goose or + /// any harness that omits `accumulatedCachedInputTokens`). + /// `Some(0)` when the harness reported zero cache hits. + pub cumulative_cache_read_tokens: Option, /// Effective model id for this turn (maps to NIP-AM `model`). `None` if the /// harness did not include the model in its usage notification. pub model: Option, @@ -239,6 +260,7 @@ impl UsageTracker { let current_output = payload.accumulated_output_tokens; let current_cost = payload.accumulated_cost; let current_total = payload.accumulated_total_tokens; + let current_cached_input = payload.accumulated_cached_input_tokens; // Determine whether this session is currently in-flight so we know // whether to set `pending`. We compute the delta regardless so that @@ -294,6 +316,21 @@ impl UsageTracker { None => None, // no baseline yet }; + // Cache-read token delta: field-local — never affects `delta_reliable` + // or the input/output deltas. Null when: no baseline exists, either + // snapshot is None (harness did not report the field), or the cumulative + // counter decreased (harness restart, overflow). + // Some(0) is a valid result when both snapshots are Some(0) — it means + // the harness confirmed zero cache hits this turn, not that data is absent. + let turn_cache_read = match self.sessions.get(session_id) { + Some(prev) => match (current_cached_input, prev.last_cached_input) { + (Some(cur), Some(p)) if cur >= p => Some(cur - p), + (Some(_), Some(_)) => None, // decrease → field-local taint + _ => None, // either snapshot absent → no delta + }, + None => None, // no baseline yet + }; + if is_in_flight { // In-flight-match: update pending with the latest cumulative values. // Baseline is NOT advanced here — it advances only on take(). @@ -305,10 +342,12 @@ impl UsageTracker { turn_output_tokens: turn_output, turn_total_tokens: turn_total, turn_cost_usd: turn_cost, + turn_cache_read_tokens: turn_cache_read, cumulative_input_tokens: current_input, cumulative_output_tokens: current_output, cumulative_total_tokens: current_total, cumulative_cost_usd: current_cost, + cumulative_cache_read_tokens: current_cached_input, model: payload.model.clone(), }); } else if self.in_flight_session.is_none() { @@ -327,6 +366,7 @@ impl UsageTracker { last_output: current_output, last_cost: current_cost, last_total: current_total, + last_cached_input: current_cached_input, }, ); } @@ -355,6 +395,7 @@ impl UsageTracker { last_output: record.cumulative_output_tokens, last_cost: record.cumulative_cost_usd, last_total: record.cumulative_total_tokens, + last_cached_input: record.cumulative_cache_read_tokens, }, ); Some(record) @@ -366,9 +407,9 @@ mod tests { use super::*; /// The camelCase key buzz-agent actually puts on the wire must land on the - /// field. A rename mismatch here would deserialize to the serde default of - /// 0, and every trial would price as if nothing had ever been cached — the - /// exact silent failure this field was added to remove. + /// field. A rename mismatch here would deserialize to None, and every trial + /// would be treated as "not reported" — the exact silent failure this field + /// was added to remove. #[test] fn cached_input_tokens_deserialize_from_the_wire_key() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ @@ -379,13 +420,14 @@ mod tests { "accumulatedCachedInputTokens": 5_033, })) .expect("payload must deserialize"); - assert_eq!(p.accumulated_cached_input_tokens, 5_033); - assert!(p.accumulated_cached_input_tokens <= p.accumulated_input_tokens); + assert_eq!(p.accumulated_cached_input_tokens, Some(5_033)); + assert!(p.accumulated_cached_input_tokens.unwrap() <= p.accumulated_input_tokens); } - /// goose does not send the field; its payloads must still deserialize. + /// goose does not send the field; its payloads must deserialize with None — + /// not zero — so that "not reported" is preserved distinct from "reported zero". #[test] - fn a_payload_without_the_cache_field_defaults_to_zero() { + fn a_payload_without_the_cache_field_deserializes_as_none() { let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ "used": 500, "contextLimit": 200_000, @@ -393,7 +435,28 @@ mod tests { "accumulatedOutputTokens": 100, })) .expect("payload must deserialize without the cache field"); - assert_eq!(p.accumulated_cached_input_tokens, 0); + assert!( + p.accumulated_cached_input_tokens.is_none(), + "absent field must be None, not Some(0)" + ); + } + + /// A harness that explicitly reports zero cache hits must produce Some(0), + /// not None — so downstream analytics can distinguish "confirmed zero" from + /// "not reported". + #[test] + fn a_payload_with_explicit_zero_cache_field_deserializes_as_some_zero() { + let p: UsageUpdatePayload = serde_json::from_value(serde_json::json!({ + "accumulatedInputTokens": 400, + "accumulatedOutputTokens": 100, + "accumulatedCachedInputTokens": 0, + })) + .expect("payload must deserialize with zero cache field"); + assert_eq!( + p.accumulated_cached_input_tokens, + Some(0), + "explicit zero must be Some(0), not None" + ); } fn payload(input: u64, output: u64, cost: Option) -> UsageUpdatePayload { @@ -402,7 +465,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -415,7 +478,7 @@ mod tests { context_limit: 0, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: None, @@ -913,7 +976,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: cost, accumulated_total_tokens: None, model: model.map(str::to_string), @@ -977,7 +1040,7 @@ mod tests { context_limit: 200_000, accumulated_input_tokens: input, accumulated_output_tokens: output, - accumulated_cached_input_tokens: 0, + accumulated_cached_input_tokens: None, accumulated_cost: None, accumulated_total_tokens: total, model: None, @@ -1132,4 +1195,320 @@ mod tests { ); assert_eq!(usage.cumulative_total_tokens, Some(250)); } + + // ── cache-read token threading ────────────────────────────────────────── + + fn payload_with_cache( + input: u64, + output: u64, + cached_input: Option, + ) -> UsageUpdatePayload { + UsageUpdatePayload { + used: input + output, + context_limit: 200_000, + accumulated_input_tokens: input, + accumulated_output_tokens: output, + accumulated_cached_input_tokens: cached_input, + accumulated_cost: None, + accumulated_total_tokens: None, + model: None, + } + } + + #[test] + fn cache_read_first_turn_produces_none_turn_delta_and_passes_cumulative_through() { + // First turn has no baseline → turn cache delta must be None, but + // cumulative_cache_read_tokens must carry the reported value through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c1"); + tracker.record("sess-c1", &payload_with_cache(1000, 200, Some(500))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "first turn: no baseline → cache delta must be None" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(500), + "cumulative cache read passes through on first turn" + ); + assert!(!usage.delta_reliable, "first turn is unreliable"); + } + + #[test] + fn cache_read_second_turn_delta_computed_correctly() { + // Second turn: cumulative cached 500 → 1200, delta = 700. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(1000, 200, Some(500))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c2"); + tracker.record("sess-c2", &payload_with_cache(2000, 350, Some(1200))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(700), + "cache delta = 1200 - 500 = 700" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(1200), + "cumulative cache passes through" + ); + } + + #[test] + fn cache_read_decrease_nulls_turn_cache_but_leaves_delta_reliable() { + // Cache counter decrease → cache delta None (field-local taint), but + // delta_reliable and input/output deltas are NOT affected. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c3"); + tracker.record("sess-c3", &payload_with_cache(1000, 200, Some(800))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c3"); + // Cache counter decreased: 800 → 50. + tracker.record("sess-c3", &payload_with_cache(1500, 300, Some(50))); + let usage = tracker.take().expect("pending"); + + assert!( + usage.delta_reliable, + "cache decrease must NOT flip delta_reliable — field-local" + ); + assert_eq!( + usage.turn_input_tokens, + Some(500), + "input/output delta unaffected by cache decrease" + ); + assert_eq!(usage.turn_output_tokens, Some(100)); + assert!( + usage.turn_cache_read_tokens.is_none(), + "cache counter decrease → turn_cache_read_tokens None (field-local taint)" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(50), + "cumulative still passes through from payload even on decrease" + ); + } + + #[test] + fn cache_read_explicit_zero_payload_after_explicit_zero_baseline_produces_some_zero_delta() { + // When both baseline and current are Some(0), turn_cache_read_tokens must + // be Some(0) — confirmed zero, not absent. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1000, 200, Some(0))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c4"); + tracker.record("sess-c4", &payload_with_cache(1500, 300, Some(0))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable); + assert_eq!( + usage.turn_cache_read_tokens, + Some(0), + "explicit zero on both sides → Some(0), not None" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(0)); + } + + #[test] + fn cache_read_threads_through_setup_notification_baseline() { + // A setup notification (before begin_turn) with a nonzero cache count + // must update the committed baseline so the first real turn gets a + // correct delta from that starting point. + let mut tracker = UsageTracker::default(); + + // Setup notification: cumulative cache = 300. + tracker.record("sess-c5", &payload_with_cache(1000, 200, Some(300))); + + tracker.begin_turn("sess-c5"); + tracker.record("sess-c5", &payload_with_cache(1500, 350, Some(700))); + let usage = tracker.take().expect("pending"); + + assert!(usage.delta_reliable, "baseline from setup: reliable"); + assert_eq!( + usage.turn_cache_read_tokens, + Some(400), + "cache delta from setup baseline: 700 - 300 = 400" + ); + assert_eq!(usage.cumulative_cache_read_tokens, Some(700)); + } + + #[test] + fn cache_read_omitted_field_produces_none_cumulative_and_no_turn_delta() { + // A harness that omits accumulatedCachedInputTokens (e.g. goose) must + // produce None cumulative_cache_read_tokens — not Some(0) — and the + // turn delta must also be None even on the second turn. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c6"); + // payload() uses None for accumulated_cached_input_tokens. + tracker.record("sess-c6", &payload(1000, 200, None)); + let t1 = tracker.take().expect("turn 1"); + + assert!( + t1.cumulative_cache_read_tokens.is_none(), + "goose-shaped payload: cumulative must be None, not Some(0)" + ); + assert!( + t1.turn_cache_read_tokens.is_none(), + "first turn always has no turn delta" + ); + + tracker.begin_turn("sess-c6"); + tracker.record("sess-c6", &payload(1500, 300, None)); + let t2 = tracker.take().expect("turn 2"); + + assert!( + t2.cumulative_cache_read_tokens.is_none(), + "continued goose session: cumulative must remain None" + ); + assert!( + t2.turn_cache_read_tokens.is_none(), + "absent field on both sides → no turn delta invented" + ); + assert!( + t2.delta_reliable, + "input/output reliability unaffected by absent cache field" + ); + } + + #[test] + fn cache_read_baseline_absent_then_present_produces_no_delta() { + // If the first turn omits the cache field (baseline stored as None) and + // the second turn reports a value, no delta can be computed — we have no + // baseline to subtract from. The cumulative value should still pass through. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload(1000, 200, None)); // no cache field + let _ = tracker.take(); + + tracker.begin_turn("sess-c7"); + tracker.record("sess-c7", &payload_with_cache(1500, 300, Some(400))); + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent baseline → no turn delta even when current has a value" + ); + assert_eq!( + usage.cumulative_cache_read_tokens, + Some(400), + "cumulative from current payload passes through" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn cache_read_baseline_present_then_absent_produces_no_delta() { + // If the first turn reports the cache field but the second omits it + // (harness switched), no delta should be produced and cumulative is None. + let mut tracker = UsageTracker::default(); + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload_with_cache(1000, 200, Some(300))); + let _ = tracker.take(); + + tracker.begin_turn("sess-c8"); + tracker.record("sess-c8", &payload(1500, 300, None)); // no cache field + let usage = tracker.take().expect("turn 2"); + + assert!( + usage.turn_cache_read_tokens.is_none(), + "absent current → no turn delta" + ); + assert!( + usage.cumulative_cache_read_tokens.is_none(), + "absent field: cumulative must be None" + ); + assert!(usage.delta_reliable, "input/output reliability unaffected"); + } + + #[test] + fn pool_omitted_cache_field_publishes_no_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent or goose payload that omits the cache field + // must NOT publish cacheReadTokens in the kind:44200 event — neither + // in turn nor cumulative counts. + // + // This is the core acceptance test for Thufir's finding: the old code + // would publish cacheReadTokens: 0 for every harness regardless of + // whether the field was reported. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-none".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: None, + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: None, // harness did not report the field + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts must be present (delta reliable)"); + assert!( + turn.cache_read_tokens.is_none(), + "omitted cache field: turn cacheReadTokens must be absent from kind:44200" + ); + + let cumulative = cumulative_counts.expect("cumulative counts always present"); + assert!( + cumulative.cache_read_tokens.is_none(), + "omitted cache field: cumulative cacheReadTokens must be absent from kind:44200" + ); + } + + #[test] + fn pool_reported_cache_field_publishes_nonzero_cache_read_tokens_in_kind44200() { + // End-to-end: a buzz-agent payload with a nonzero cache count must + // publish cacheReadTokens in both turn and cumulative counts. + use crate::pool::build_turn_metric_counts; + + let usage = TurnUsage { + session_id: "sess-pool-some".into(), + turn_seq: 2, + delta_reliable: true, + turn_input_tokens: Some(400), + turn_output_tokens: Some(100), + turn_total_tokens: None, + turn_cost_usd: None, + turn_cache_read_tokens: Some(300), + cumulative_input_tokens: 700, + cumulative_output_tokens: 200, + cumulative_total_tokens: None, + cumulative_cost_usd: None, + cumulative_cache_read_tokens: Some(600), + model: None, + }; + + let (turn_counts, cumulative_counts) = build_turn_metric_counts(&usage); + + let turn = turn_counts.expect("turn counts present"); + assert_eq!( + turn.cache_read_tokens, + Some(300), + "nonzero turn cache: must appear in kind:44200 turn counts" + ); + + let cumulative = cumulative_counts.expect("cumulative counts present"); + assert_eq!( + cumulative.cache_read_tokens, + Some(600), + "nonzero cumulative cache: must appear in kind:44200 cumulative counts" + ); + } } From eb049ddf815d48195e1713afe039d28c950d7933 Mon Sep 17 00:00:00 2001 From: Tyler <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 31 Jul 2026 22:01:17 -0400 Subject: [PATCH 50/87] =?UTF-8?q?feat(desktop):=20Agent=20Trading=20Cards?= =?UTF-8?q?=20=E2=80=94=20mintable=20agent-snapshot=20card=20PNGs=20with?= =?UTF-8?q?=20optional=20NIP-44=20lock=20(#3278)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Agent Trading Cards "Create Agent Card" action in the agent panel that mints an AI-generated trading card PNG which **is** the agent: the card carries the `buzz_agent_snapshot` tEXt chunk and is drag-in importable like any snapshot PNG. ### What's in here - **Mint pipeline (Rust):** one OpenAI Responses call — `gpt-5.6-sol` as card designer with `gpt-image-2` via the `image_generation` tool (~2–3 min). New `mint_agent_card` / `save_agent_card` commands; preview with reroll; save or send as `.agent.png` with round-trip verification before any bytes leave the app. - **Snapshot/chunk work stays in Rust,** reusing the existing encoder/decoder seams (byte-compat golden vector proves the plain path is identical to the pre-envelope encoder for placeholder, PNG-injection, and JPEG-transcode paths). - **Locked cards (NIP-44):** optional `buzz-agent-snapshot-encrypted` envelope encrypted to the (owner, agent) pair. `parse_canonical_pubkey` performs lift-x curve validation before any API spend; wrong-key decrypt returns a fixed refusal; the plain decoder refuses locked cards. - **Guardrails:** 10 MiB ceiling on final bytes, memory structurally `none` in the snapshot, full-manifest import disclosure, API-key hygiene via env layering (record > persona > global > process), fail-early validation ordering (all key/lock/NIP-44-cap checks before Responses spend). - **Import side:** full-manifest disclosure dialog, locked-card import disclosure, bounded avatar fetch. ### Review Code reviewed by Wren across the full arc; final locked-card cross-review **APPROVED 9/9/9** at exactly this head (`64f819dc8`), with independent same-SHA verification: Rust lib 1,843/1,843, clippy `--all-targets -D warnings`, desktop file-size gate. ### Live-mint evidence (real API, shipping seams, this SHA) - **Plain (Honey):** 188s, 1500x2250, 5,101,503 bytes (< 10 MiB); decoded manifest == built manifest; memory=none. - **Locked (Fizz):** 176s, 4,670,184 bytes; owner-key and agent-key decrypt both verified via logical manifest compare; wrong-key refusal exact; plain decoder refuses. - **Live finding:** built-in agents' ~171 KB inline avatars exceed the NIP-44 65,535-byte plaintext cap and the fail-early guard fires before API spend — clean error path, noted as a UX follow-up for large-avatar agents choosing lock. Full evidence (cards + dialog screenshots) posted in the originating thread. --------- Signed-off-by: Tyler Longwell Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz> --- desktop/src-tauri/assets/card_template.png | Bin 0 -> 806934 bytes .../src-tauri/src/commands/media_download.rs | 9 +- .../src-tauri/src/commands/personas/card.rs | 972 ++++++++++++++++++ .../src/commands/personas/card/tests.rs | 324 ++++++ .../src-tauri/src/commands/personas/mod.rs | 9 +- .../src/commands/personas/snapshot.rs | 45 +- .../personas/snapshot/fidelity_tests.rs | 61 ++ .../src/commands/personas/snapshot/import.rs | 199 +++- .../src/commands/personas/snapshot/tests.rs | 53 +- .../personas/snapshot/tests_encode_size.rs | 55 + .../personas/snapshot/tests_locked.rs | 129 +++ .../personas/snapshot/tests_memory_entries.rs | 55 + desktop/src-tauri/src/lib.rs | 11 +- .../src/managed_agents/agent_snapshot.rs | 562 +--------- .../managed_agents/agent_snapshot_envelope.rs | 638 ++++++++++++ .../managed_agents/agent_snapshot_tests.rs | 599 +++++++++++ desktop/src-tauri/src/managed_agents/mod.rs | 1 + .../features/agents/cardMintStore.test.mjs | 170 +++ desktop/src/features/agents/cardMintStore.ts | 227 ++++ .../agents/lib/agentCardGalleryState.test.mjs | 96 ++ .../agents/lib/agentCardGalleryState.ts | 35 + .../agents/ui/AgentCardMintDialog.tsx | 364 +++++++ .../agents/ui/AgentCardViewerDialog.tsx | 369 +++++++ .../agents/ui/AgentManagementDialogs.tsx | 2 + .../agents/ui/AgentSnapshotImportDialog.tsx | 76 +- .../agents/ui/CardMintComposerChip.tsx | 78 ++ .../ui/agentSnapshotImportDialog.test.mjs | 64 +- .../ui/ChannelComposerActivityAccessory.tsx | 4 + .../src/features/channels/ui/ChannelPane.tsx | 7 +- .../features/profile/ui/UserProfilePanel.tsx | 37 +- .../profile/ui/UserProfilePanelSections.tsx | 195 +--- .../profile/ui/UserProfilePersonaDialogs.tsx | 52 + .../profile/ui/UserProfilePrimaryActions.tsx | 217 ++++ desktop/src/shared/api/tauriPersonas.ts | 112 ++ desktop/src/testing/e2eBridge.ts | 11 + 35 files changed, 4985 insertions(+), 853 deletions(-) create mode 100644 desktop/src-tauri/assets/card_template.png create mode 100644 desktop/src-tauri/src/commands/personas/card.rs create mode 100644 desktop/src-tauri/src/commands/personas/card/tests.rs create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs create mode 100644 desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs create mode 100644 desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs create mode 100644 desktop/src/features/agents/cardMintStore.test.mjs create mode 100644 desktop/src/features/agents/cardMintStore.ts create mode 100644 desktop/src/features/agents/lib/agentCardGalleryState.test.mjs create mode 100644 desktop/src/features/agents/lib/agentCardGalleryState.ts create mode 100644 desktop/src/features/agents/ui/AgentCardMintDialog.tsx create mode 100644 desktop/src/features/agents/ui/AgentCardViewerDialog.tsx create mode 100644 desktop/src/features/agents/ui/CardMintComposerChip.tsx create mode 100644 desktop/src/features/profile/ui/UserProfilePrimaryActions.tsx diff --git a/desktop/src-tauri/assets/card_template.png b/desktop/src-tauri/assets/card_template.png new file mode 100644 index 0000000000000000000000000000000000000000..2225d1d442bd0b9cd279edfe19d0b483b0563153 GIT binary patch literal 806934 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Ƿmv$I>f{^K!~H2VPQ2x0&z+ojz2Y}o#9UbB<2=w4ngFY zni(*n&H_R{YhCgvpAh1EFX;Nk?RaXwRwvf2GZCor(_bA#$lHR zBufY+TEl=b;Y2seapD+8?BdnNP==%W$4AwU0h* zi1s>j`SO9D&6t-Tv8O)IrnB?rpNu~E9y}QR>DUoFf}c!wcDC-hax`3=>ks?m(cW~= z4f>|jF=gpi7V-y<40?6~i>b}tzqE60eRAT|!G6!3I6fE*`*&`S-+JTL#M_IT<;Wq= zGHWehX1}RjURjyl=-xZezI4Tj`Rw7h){u&UBb~cT{AVKQy0h?Uh?&veqcv zXRS^b>CCL|hX4XboIr?|W)e(K5{@hj?PP9IHv~|fQDfN=mfPZ9W;I&Yk0eOE_><-% z2_{ZYE5#%Tx+)hrORenmWy~F>Lj#=nU6;v#?8a1QM_7Gc0q@##9dI-WJ6_zNcOT_gx3^z!m zRYzcWzI1*(8uh!`a5yZTbIyBLI_C`gzxI^_XO3mxfAMZHE&ux0SME*dJ1-ZHJ+StL zk1iZPz=MTsVY$uihO`%Flm%bujF$-O&)yKZ{SI&Osi(9*+?|k#s_ugC2au`d6W6#z5+!$D6tYLts zMLC-0)A97~2R|CE-#+%KU+=D-DfhNqIoi0pv%S4LH-`lY`=Z$_i*=g)ob4+s6u7ytTy_T``b`rhW1bFaU-x$Tf=J3A2lsHPzMI5`T`h}&?1(Mk)v<4JDHaSw#NmktSI;t*L zPJdO208$M&08ygJ_fkU_r#f2J3n{2;6>2bXRtnLK7BlI!Mxr!QLW^XFt-Vo@76>`T zqc*vQoJk614npWBv;;(sm;f1#oX|94T@_~!VWOl)R%(|gyW)87u;^9geJC6j;@u?3 zAZV5j@{FodK0{E5-l!tg(InMDYgU22+d~I~IEDgOq7>-wk+eJ9(iKHzgp_2G;>SWN zlah&N*b;Gq+|&Uxpk{t@h+Gkx5-}Z-0;)8RSIRO;)72pB`9@9=AY`FXHAnU9OWQi2 z;Y`Un!0f&EMd8M~dk0pBr%oK47H(RU-_`p+FYbv(atesj>Z?_Qg{f75>9 zxwS`6m;-D5rPcYld9sU(=ICe4v9GY(@E3m5-+F`4WpCZIU{{tCS4^+>v z_Rh{|XLD|Gc;M*49IhN~WyGCcmt2{-=~9m#JUblD4PSm|uirOc{?yvsu)BVzJoo0U zu`?Gp%aNlz%dD|5X7;$*<(1Xh+s}K?-j}XCI+s1T+UfTAfyYjCvdOh;ci%sM?-xJ0 z_~3H6b#HGt=o}cDm43OtjrN6w=S$TvvFC03uwz{--^|kL^e&f%MJn+<4fBk>`_>+e(zWw@p@85RllnfBh z_wCN4m^iYAyr#poNR8`TGB9w|AXz;b0VLc^L#S>Ixey7>w^R=x>8-h;1gwXkamQXY zsVTb#s&Cz={gP`G`Ql;LGkyW|4K%|j=_1t*vK)KFIXOU}RJ{?+fA#UulIMgdW#jn1 z2#XXXx_8JQNSg<%nQ3F%(@>;IsCN*Hk;bORp>TL@NF0YKO=4k)CgoD7PNQ^3NB5=_ zx0>J(WeHF+JvF}8=94(*fWn4xB5FaDD7K0qqeH36rh&#of-5a(s4D7z!H7L&3`ZC0 zfqIM(1K!k5Je135(&N+yTQUDrRrG970Vos+0?)Em5bJh#b`r!%kDlZq+>Wv=fC4;9 zCnbjkmHbzbn0}QmN~X5J#7C-*2SV6|L`0Ze|0{2dJ{zFz7-AVh;vOeC9!PddGQLGw z&Jl4bQHMmzxl)7cS-6R`2qi41WOdH0rYj^fO*M{?IXMAYlm{&~9Vk&rJtPZn|JuLB zqOyz*vuHF>b5A|Y&{dfDcf;=`1Z{u`4b@C&?Abf#r_*9I8XY;jbo}t@cr+bPin1)d z_ZGCbJ6;xQKl<3*BS*}MlZ%5s=L2Joe8!&rYt+g3_8;Kx zw@{AA_sU|*g>$YfrxQ12R~YA*nAub5m>n0+6EK~)&D$H((eBEjh1DZV)|koe#4~4E z=6JHRxo0`^rf;nK)JK=+=6iS7CvUxRYp=AIHp@N7)*54NI6>|5%Ia)D#ojyir7Mrm zWe=`)Is-iL*fB%XyX$xNHaD@i)3@&Ef!3CAODdjs`cP_v7t({vJhifMepE)|++&q7A^T6uh zp_Bb?UM}_h)2I7i`s6}4FWC9zhd)V^O&V>?tsFjd>J*LdU3~uEZQi-?%ol(87yst3 zX0-9b^RM2xJ0aUOnHhBKXj~Q!fjGcg(x!0*O2{a9S=D<#8M!M8L9&q3GXtfTQ=LG= zS0SYvN)gU2{!i^8BpIfP=(4x@E+pA5RNv70udntNo|Kjrf1U2#EP*k&;{IEhR&Khh<1_WAY^x-z*gqs?j zGV=Km-fh&WGSCTPQ0NymztYer&54V}4)5c=3y+E}aBok_X1%abXni*m}wR5zO=$}NoXOvnHta;0NWjyyZRhmL1| z~2KX~-u2XDOam;c@K4}AVt|D*rofAOg=e)|9RU;d-#fArz}(t(3(3#$Wjd%KuA zwwav;fE$7bFq7|-W|oJAEeA23)^w0UGaJE>;V4uixn_)DB39Im*kv-y|4pQ`&_fh= zO+}9jnwpeSa|si+5l4@2^ub1^N%e&DPQ!DKMde38`s;YTTes*2;`zQdSjFDq;?*9zgwZi*Y_g43$n43T-0C z`=mkQWDPe#48aom5{N)P)?w;3?H=(F2qaOcw=J4%l9Z*((F!gqAq5m?j$n**a3}y| zA-t|4{Km&a(k>#RJBe(Sqc8+kHrM-jQZjRnlor+-FP26Zh1LnwselefOlhi@t{emV z1lU+*ZAhfrEOv_Oj~py*p!M))b^-M&hod%5#G^>urWohev^pKnr-(mHBzV12#ww)N zB#B`SW)y$|YQjVi00D3m2Z@qegIj8<6}H+fcem{5c0^lkhrxh>ftD=5)4 zDGmU|0VDwsC`>g^S(QWOP&vMQ^K{=G&N+Ln{^Q)U_uBj1S5gIo0TeRdyY~!xuf5jy z{l4YD|DXNMpZMxyFaDEX#R2W~>~ssBer)dX$Btlo!7P2G`@pk>&0zJP>HJ$twSbzO z6L4W|R^++0xwS=JkS)Lx6@-N)wg5y>vH+2Yh%F#0LflyG?QQH%&(F*sKh;@!c;n9f zy&G!<&-C_9mJOTTBuSI8I-7~vjpX)iI@O^$Qo2!n49-T4?VZ^^jE&p zTzaaR_IIv)@7k~a(e~Pa62*z85pm8f1JH!+eHe~dDJXk?46Fc&02L6xXsi{t%xHl} z8RwR@21|)8pxIkZ8lBtgnKJ5UzIf`)*%{Vs_x1gY%Tp)MANt7W^4)vsp{H9<{q21D zC0=`r?0#!%>WSw+aregEi+}tt-+%SH&;5nJ@y~zjfBx0~;a~hu|KtDm{?*=zS&QAP!`Z;C>cpMtUp7FHM$INiY- zv3CF5D8obWU1?W6UrI^&KZ<}7ug4T!E^jdwAXxba0R#4t7=ML)4Ixf-Y;de6sQG-3 zAiGoaVmK^oqO4U>QMz~4X*%MN`JjKtf{Mu^+*%UGIeH%I-(l=^k@)Di6bmbrm~Ic= z#p}fil0xoYV-$IL?DlC9Ivs$;I26IiK*ivEfKi}4ACwhe9j~fVkUiuLYtJ!@*AqV^ z$uGFU3^0j09%RdjKPLl{f=&=r5wW$*cbJF-4gk{C)Pw`?2+GwHMPQ*g2w}{RXU`cD zZ<{=u1ubFGt>WNa0|X`_D)PJ-W`n~^Gu>vgv%QsD%0{Dt$QH1%e(%)Dx&Pw-`S*VL z7w7Wpzco8;*7wZr#@@N(Gta#6aMGBD_L-S8pSXYFyTiM`fBYjx&&}Jx5Q-cu+iXbr zh>8M;^UP+2wQPvlS|TD#Ra`k)VA22uR#{y}S*5o&cDB~vnLc!K@zkRW=RSL9m2Usw zk{zu8xo$tRLX5rKrB-8Zp*$zCB}+`UIMG#SW|MXYPCsOIw-WwE# zZ^2vu3{`y?HS+}!!%gPdj{v01z@1RgTXkxJ50qG@@)bl7ij~0T&QV7fCaBlY&-Kv- z+WF}Db&2m%U0#;; zt}7_9B7LCyFW^FG+FiU&|=1zzl&tDfo>OL?5!^7WdmDkL9SN zHZTe+2_e@+6h=@no^Wn2_S+oRSY_f^EylDcASDiljzL-?*Ft4HR|h~WYaE1CNjrMD zAfRmdmH(xLKQVA&^P-^9Xs|ffo^CgKz5OiDiXsPwo$bARxbav1;+Oy7KmPBY{n+$N zzxEF=ynN&BH+P$9=kdo+fZfxIrsk*6=6iSF#=Td%$zZU#O~YZ@)JRke2iYJS4GJo# z7!|{z&4!$h5Lnx+C~{&dNuI>SG$vWIQUFM0z?B6|n$1>f6^wRv_~jpcu)WhWY5!v% zTj)+T*6;7X^UlVQlG}UvpkQN+)Yx%`KF7prnObYmc?nnT4b~rB0ta)M(PJ zJL~VfyMFSpT1wVo^^FU!-M+V#=#HA3PFgKxb1n)-g{asPSi=4Yb4Dr~LCluRi3N;u z7!u?b_A}V+!_ELV_6QK4Jl{TktabHXmL~L*KR*A&$7UdHGHWDObH|f~GkYIgTlwKP z`*&B;v^mu=uy@05zJuKbed5QUH4VioV9zwu!^aNI%}m{X=cNlj{MPaFkN(o%{M+YG zFTDH4OCQ|a(P>+ms#NEI*(fyCqGv65gNVYDUiAb+_f|~;Ej1q^GN4*l5huCsIKUi7 z%J9emC+xUxi$bq-6OOu2WKySFLciK@=(&H9$V&S5m~xwwrQu*WJJp!&qw5B|~ruwOj+^r>Sf7q4BqI@n!rCA&CWRybm2%3*)A-`nmh0xD>@ zU*to|Gh1Ywjd--rwg4iu1=ym@CMiXmW65Rt$SXu^(Xw7TcBnN!*Wb6BYg;!i-==(c z`0(`F&gLgRu{1TEt}YMXyRbH7b!VsO7i@G{Y3TBq*K>0V$8B~1 zv=$h#Aj}B{8T5wGFJOO!`+Yio$h`3JZm!JLoB0zDHh=c3^Gl~%xtUS~tY*~06Eqxb zy!2-qZ(lBKN-8m}R%dn|R0E13ZC;?!O`JZb7oP^x1iQ^NXm{F24j;<F6rCO+GAA=Z^C>n&t)+v5CheM+9!DNw>B6C6nzoV)}q%Wr0LpbnP za`X(=9(9CTH!gT`19R&E(c=Na3`BntgLn^&ws0XO#Jcr8d|a^hVFYPLHs?6Z=nO&! zoSs0*aVSSih*0xJLRJ|PhJmMu!>K}VPi{$&53BxAGBL~w%hFO^|GP>#KHcsx4ZEd{`~X*=>PX0pF2W7`d|M3rEmSw znWtVj{pjPvyKg;kys@{RUA(y5nVNq1@w0o|z5Dl85=F*9F|w8z!3=t^wVvez2Ex&x z$aAtej)pcHf-QhS7X{gzN_7Ph%LrqmaFjt&sD;Dx%PVy8!sgEA5EV}^HqV}&S>Gz| zZSFn){E<$#v9dgR_nozj^sU{ZU$8M+YpsfBl(MfBAd(r2IRqzegy0-jQHPjdTsv3j{!eq*rK&qjs; z*cLi%m}ZL+m=vl84A*(*O@J+&{RGZDhYAq4m4U^Dxuw~;_uqWyl|T76)6?yr`<0*k z+>f7LySsYft((J~+pR`fR#kCS(Y3T7Fq;AP_7(j&$v>On^0+9!DogD zKD(ep3E4gPnUFmGiQoaB;DK?$SJ@GL-?mcbO#&~uDFKK7k^|0iw3tN^Qj#s=si-U% zg%Gg~P!142WUpjFG?Jl{5lFEjAxcJma25_IL*pt(344wZcz|Sjpu`Iy$~G@}pts<; zh#ufr7@@|Ud_Q>dC>ntj@$8cj6%v(y98j}E^-Q<{v3TqSqeRAp1zVaQsiOq`K_RDK_@PGmFvlxDbpm zIpRiySE7=J%c(Mlb&woIf@pcm`HTEkC?v6BVCjZXEH6_~{}S1vu-1-7BcpI`x;f|% z23bDJ?bgQji9_bU_<#NK-}w)}bnm4<_|`xFpYq+}*vSXGr=GZfbANE>^;Q#fV%YNK z%l9_6`ez?KGdnYT@AlT-UN3DJ02~dhwOC}Pw_}HcLIK*0vXLz^D25DZ08~CKio%wD zS5$~VgF!>3r@zR#yFNt&&@C3xT|3q*jjs{qaK_u2Pli}7T&(Q`PPNCBlCD_5tHZ+4Y<|9sVQv%Mnkp)%urY$RwIj}f(JSFa>y;8KiPQhshNz_ z-R1nLGwD}e(8tapbeOGCorhum6L!B(E3fH%#WdSonEjm*6c)&u#GuAJ8xmOpMw2Me z(7(&Qx0Gg_|17l6qS{xmKhEzXMU8HDU{U^CcZ|e{%|6I6EirthHa66+#rrbG&5>!AE_4!w&mcI5h)-2 zaIkiyM)tI2VJi$f=!iAW*90sR6gBb8)loSSHMCL`F(q3J&wzAxR zq6a&cXgVbvM6rAvN9ei7ITYLnn~zu$RTFrc#Edg5OelqvMbgc z>;BUDc_@aOErfcYpgbj3>0?DvSZlLvgj~$cw6fu-KN@Xs_YC*{%3pZ$zxl6!`Qf9Z z@BfQ`cJcccn%$#E4j)Za(mnNYXmsBH{ z9FB;|j0QxRB8&TbOgSn7$~n)JEdbakFnL~-PS^5_D22eRQ1$z2zYk~5%nkZ>Wi8hl z&zwn5pG{gV+}s>pxzyj-H@$55+0PznciU^r*`;^ahoo-q+JOb5P1V%^0364y0dN_& zM%Eths&n(nR2Lt3=uoq*Zrxsc>)rJu^LTQeQUhxnFc<)p)rlpeAThuidwpKnq^%z0 zj6kRr7>$-;ki#&8{SodCXu6|6{;|2^$2zy~XDx+aex~!}Bf2?{*kDXf!I57gbAoPt zPwl?1Q4u7zu&Kt;er^XNo0CqINhm8aJIaA9Xhfw55~Wbv51@Y;o5x`KIZWrk?ldr4 znC&jk&Tie^fBhS;B#?dX&;RVt|I(+m-GB4VYk7etG49b-N=eiWa^U9*N3pVy4WM}O zx+&5^uoerB5ch_|GrfesNHOzxBT^^~i3lNq@E|0)gjcZ`AxfJ+h@{eSKOpeyiyQ(! z(iS*q5s(?_1A@TR=Tn=tUAX4dMKAWX8s zk`9to1~T5;DIlWxJNkrR;oS_)iQyg|th|9tRS2s=xV}MVbB;?g5o_l{E@Jl&gZ#Um z<>F00WHV&o_M~q8X#)65(-m?`ZkoYQ3gPriQ^oiHS5fLF- z$a4Z9wm`(hpcFDwYE+h)mDS?Gv-3oBl+Wpa`3#$Xd4|Z*-)i5P36cGVTw{?7mH8+n#s%BYk9 z9g6f>PVvGpZ-$x`E^tUngC*BVhd~dR1N1>f-eHD+w|{)Elx603aBh~Mz(_jIf#wVj zWP-c)2TcHQi6W#{Pt|O$ANplNYbu5oY`N8P@D1Z1!@(V@Jhc_kz#HQ0vkbpV@ByE; z=AW$&_!Cuuk<-rUIhcdTDeV3Wf2pTiK*W<}uAP$+4#=PXw>?22iz-BzMUQLv$cNEX zTvLA3^v2R%iSbQlfn5posr9eOHA>h4-*=MT*+V>W;@{y3TH#3-Q3z`y76(rHpuvnX zeoZFbugDj}2R^S8t*S0*?*I{K{YPTxM(4Us`M@_jK$7#p+L{TF3+Bi$9JPHo3WGbq zJRkpExn%rXK6d=_9e9aDc8o|HoYa}}UyQ~0GXBwHVv)6$xXAKhk>^uWt*pp5mNysL z*?;tZed=%g^+)=5um9;k{Wsj>!%GWBH5*e)^XH#mdg^Bf{mpOw_kZ`jZ~pG#L#q;( zSs*Cl@flctQ|B==VuCm}y&{5AIA(Au2H?U15fI}jgTa8a0#t%jkrA?ttp#GFlIx~{ z87y<2gR+nnIk>{$K6%NAU2c zuyHK8{bn<@24;2Z+_h^rzV@I0vZKwNiPWC8kksP8D#5!&z`-RH}FzSc+bQ z_PZY<#xEK(bIrlwmvy=LurU3Lt)HDxAVM7ktxd-q;s}r&H&mJ9DFRj?8>k^boPgBJ z4$3c&L81$bq;>Y^NQ`kkp2HKk9hkl95Q2sc%JI9uo<0mZz$7qwlOB)FS^z$yWV||r zrjH0iCG)0$1cPBd&J>QmR^gKL;pq9TyB@ z*8NBGB`*g`hOuCU$Dcyz3mF2wS`eQtsFm(Nx=J}JQ)ES35vA%F{s4I=DlysBi9|KB zLUDm&;JI-zj08x)Vi?2fHmtlM$X;X+#L$Gv_|F_{2}zG=23C|Jf^F|5y8)y`*EV+#g(D%g&x^pFS}&+V3ylANC7wHdM!` zKB=o8Y+k#y^VlOtpM36-%NOq5{9t`%x;5L?#&DkJL}|O(%(KyOm?M%>iYzJQ>FI_x ztt%f4vwdo&gWevTedyTCY_hqw-rw(2p$g_agM0|KK(frls1zeI5dy2=4CtF$1|nt~ z00LIgudGm6f{dWBP~-rJy#enJxT(gfB4V(X8I@8%mPH?!wZ`1y`fg#3p)9-n=G{N{ zV~0QT#JnB$hYFJ;4=1O8)|#XC=GW==?9h^V(-8*Se-hdztPUl=XlZJng0?kJsWfBSddd-qRY zALJ%4ETCl`BhC0=noZooz;FSG2Jp4x8pc%~%E~T21`Bc6K_>zPl#C~a7_vX{_!TUK zVVBXZhB&!X5H>VL zu{GlZ>BR1c$KbKQpZ{+Eh~Rm4W*+3)fjJEVy<(1Ez@kiSEgc->-1QdQh{DCm6&&?40i^%upsH z6zRUSVFs-I49_H!JQjghJ$O;(0Q4UAnM8_Htq2Ar@ot{trqrdmrU;6WhFTe8A|L~u^qjP=o==?%wZuZ1;OV9pd z``AM_-}w5sf9)UMxp)_wsh({Q1|y|a!T9c#{o8j3k3ZCW^n7DyCtuqr*yx5L#d>3x zUi`tWrNcYVKKH<(W7Ds{a&LEUczCH{j2&(aj?6V%&1O~1k$EuA*Z>MmZt*`wj$ z@Vsd)EIxep7!3E;?k*39xl+mkT2iA-4@M@lR4`ajpwNx;RCzBs9^M-c*46R(9+g65 z00Jqz0`&kSb8X1yTjPBOagI4B}uoLd!eL8&~t)*%MD3 z`Rsqd$<%214PO3LGx$L3b~a=Ugb8B`c`?dI`&pJF1LZ}kb&_gkBqjz+3`7nXzyEcb|5S49k)7B7@a{{0yf@lyc7V{J(tuS!A~P@n zYe&-cF{R9MQ0mXhZUw6m5kgK3W0*x!Eq)yCvDIcHA>{%U>i!-{C?>lRGK*HNpC6#4 z>;=_^v$ZkTD1Ksj78q_hNfN!4;Di^Ph=shXb}sdV=JB)XY3d>(HBi7YNLC>RHIc&s zbEuVd$64RHX7}Y$4AE=dq0l}kj=@2>QJq>il1#eIrIP6h01^u!5X)8;S85R71`*+V zQ74=+=udc&J95Onl>)#3T|lD0sBI9@lTl9a$i<+rNCv8RZ~_=)=)++M^?*3(nCXcx zT=g8Huw*AA;&3)5HYX4sDHoFB69draFH4xH%n)N{k(h-XS-qoi4hOP_@q`^QL^rXkQVpB0BfC6pGm}JH-os;0pz6>V|RbQP4+K;`Rw2QyH8K0E5G&+ zzV^ne8>ddqoIBo}n?8QxL_y87a;{>q!*`qm(a=0Z9cpv?-UFdvcDw$)Yz z@tg1UmuKtJN^WK{h?pEQl{%>o2clbF+=JA31UC?D1|2yUVxsH#VREqcA5_fRJ-B)cs74 z3d}7_Hh#5oaed7Uv>N07j{=0Tm;(`rm@E_)astXBD1z5`j zP^P&tu_Zu8qoHA-ImLsW{_Qom``!;ReQ@dMG~EBbPAyS~^Na`!0>x}pkq`UXa95!w z)b%|wxeYOK{D3wK)(<5W zE2EC$b!)ZD&%{Z@Zl6)*l>{7M8`v$E;X8a_%K~fQ@Zi)KVKkX}Lh@3CM;9k@oai6* zf2>6L2Z7aYoZKppJEGEbM8gAM{t!i=#58;9{fAEwFioK?gRlPqCP ztu^r(V$t-SbSZH1Y8~wvWq2xCOT@Ndw*67w+u8lplXL&c-}%&YpP2p5H(vhQZ@yk+ z=|g8u9iLAZPCWm}mwu(QbYlJe-?{MJ|K|Po@7QJo0EYbnhz%f-gSC)R&}gV$pMUhu z@aU5M=tIp#TU@;{8fkzKYhabIo_wwbfFMjFlnTKaydGX%Pa_{J3vbwc@bio`s znyziqdzW?);F-@n{@~;14ee~*zJB-K3K9XR@^MlOmJmo6c}j&MF34KY3nR!PY`iz%U15r8VRQj|xV>#3)OEdVF}XmZ?&B zTT8?U3PEA2d1_jtg~5*9x%tLi^PJ@cL=CiMi=A=od_hGn6#-) zOPQuhGRlTT1YB6Hp%^hEr)={g=MkA!gVTm-%@~vHF5e$)+*&x?U>-n{7M1`|8-Rj| zIPjAN^NdPIUj;YUCLmmgXgm4O7&$1IjI419 zFeDFFvtg9ES3b%}^0}*{iwl5fB2Wqem&v-M;2*;{N2IzlRXann2jUWPW{dZs?;PfAd=hk5aVty2fiJS8~?s$a*=_|fIu=!#{mLZT;b6B zCqyM;(?gU;d=7dbQPak?ssgnSgAzpxRj6$WeO3&SR>9}&$t_4!RpY%xp_lRjd#V-J zC8J(q9LzxkC&MQThL1>C2DzsceZxyt!@)qAj)5pxf;DZ&6|ML&M@YU_*moEO+f4;l+>V7d)&8F>HDnKC= zL^KAjfEq`fL19U2Fo<{7=>Gcf=_ky`Ki0l=XLS7zWeCk_=%l#5*ZO0 zoxAb==7m>oPPg<_8~5(y*KQ0BpE~;KpZa`zW@hEWtDD!~%}1G536TN*_x`%eE>;Uq3qY%H6Dn4wZxKAb$%dbYQsNn>lDa z9e}q*oSls1^x-1`Fbaa0a*os%fk2_Iaq-;~jKbb9kaX0v7UM=N#v(|FABs9%T0%)& z-#h@&Gf%QSNY{%@ymbCyu1h;6zA7@ts*aX%JAAO;y7Ed&#_B;_0^2pg_TqaY@-UA= z_bRj7f#qiHUV+%xBM-Xlxl#uy2%Pk1qAUa@YaHVqR;?K+WGD<1`Y#a7b-3<=L5yc* z@NYAbh>7sT_YNl=_@mf>Q22^IkvX6WaebwcemU1Xg&=xO-u?*&9i9~K63D+?K9m^I zKn+J$^*lYEkmK%ktEgJ5zCCn`#u-2e8GC{#mjDIVY_Hd$duu>{ESOcU6 z(-h~HQiL1`o2`tpwFHMSu=}H)Zl-BdAz9`DaGn);mgO3h1y(u%r9eY_CeclUNs^ef z4R+Ye*m9~2F>7GX@DNty!`JZ?0UdK3cC z9Gvt0Nt!u`T@tB$@#?^VA)-!3LiUl(Ab(My=5yv3pY`LK_x^PyCy5M!${+zUFl5W-UHlMM{UOhFhc)Ot)(4IfdNSbWYn{?o-# zbEL?UtFW6(vrKGYbSGaFk~N6w$-n?eer*(fp{mW!B5THp1YOL!3>pnmf(8DG#Y_{N z#Du|zvNOmdn8$6pd72-WSDzcJehG@a^J>_HaxxoQ3xUExiU?V7aJeSrb5(?YCC7h3j90vfgMAj-xX0ya8X~`449P&k zKb&v@Clsu~Xfg0vI%$P`$-G$txHb-9+1V8FyMfl!FGvU3dBdPP_jSA=3S}_8VBoSW z!O8|?^<*JRS{u|9zA$OxSkq)gHAO{imlr>_tRl)x+?pv|D55d42nJ~QXsKaFG2ILo zK`Fu#%-PUmSH6d2iO7~gV>^4puYR=s-~GLF_g9wx)Bo_&m8-+~BXh?NcNgc6eC8{E z@gtx5{JpDx_R1gszX$tQwMi(~MNUNlMGm%LDgYVEDS`;U0!9W>7NI1I6LG7B%NzC^ z-zuJbIC^*)5Ycj8Q4(ayvIDwORZ&eowHlCs!g7_QvX!ODa*@$+Xc1v; zlc3K_Q>qM6W-L1vHnOypje2`I7tKT| zOp6@)BWgB{N=!D~8;x>HCGxvyBn^|K77CMUl{VQVz)5pf>ts0UXBxC-q`XiXo3rzy ze%@Q%V{S1k0%o$Pk&!!djii&P#?8yIL@nN1{LL} zaER{>nN3c6=r5xWpu?Jih^0AQO7ANro)D%^lC*Fz$_~vG5MV8kq4t;RdVQ$*PA81P zC!9({`7no!vA}{_J1|xQAZFL`_Og1fHuh3QmyrshwHCM&#=8Imi}QNeq+y;Uf02EV zz!wj(_X;P3cC*_lLt1~N)et9mp^0Xx9AV#jU~&B9uz`rKLgf&`J~>?+U3G(k5EjQa zqx8LobWk}8ah<=ax^x~%00i8XW< z(_j9jzuIiUkG}SwuHF7K)3s^Rr6RLziNF?|Wkr@V6I#nmfCd$C;hrl-i+}`{i3o|A zEzh;EnZg_IWtXq%k3WT~a`X8Q$MD3t(=3IUnO5+SCDhFCKoqasEE&_*+$WhR8uxf)S)%QedcYk=6Ul)_?E z>}>20`WZ0fIZz6WKnR4+6c?wem&;=*0w;!MCm9snNK7L^RI1VG8m$^>PbImveIghR zY??5nb4IoD!JbBKT1{JQ+M>@)7Lxv8Xl$Og6O%R=6tGf>u@-bQZKs9`HD-Av?a@v# z+U+Zy4u(`3m$e?}u%yitl!YW|PqmU%r->C!4JKeMbnYM>75?Zc=m6!;S+=nv3=s+l zwHxYlK&6>jl|vYtQrFyZ8Q%qBKp0wG0z8%10V9L~Q2~i52J<@C3HXJICbcE2Gj4UT z!>}355U_3hrC}C(cI>qXyOHedh8PPOvBo=gP8MI`vvMb2mYpdM^%4jHUI8N5NszJ| zx&g%whaLmMEQqo!Ya<9^E21q{a@IK*Kf9ij^a2HS6{FN_y0#ccr%{NiLtkn+1w$w* z#<*N*4_+4|@^O7En#6 zkR=&J&Yd`9E(K9Df-J&{{kw)NM7z$@BTn@k`l4mVG=m`UM&&NoVWDMDON}3SkA-(W zbom}I-~n<=7Sf`i+3H>-Ap$`Vc5H;nDDbkx8*kEq9GB&09`e30kTxSQp%-K$g!hl7 zC?ix5V?;yc07HYmD<(%b?Ct=MEtHjuBC=3N?O{KQLkD22L@bVK;zcChLC#$AxGfh7 zhWRMpE3}2tU^sl}(PuyRQ$MqD>jyvj!++G!tL>R-8nHE*QjAPRPFdd;18Xy;f|)@n zR2ADvU`Wa?0RSQsSmMEmjpAkpw|o2t-=X)e;pd+>rl)&eL6#sVO-QA#7E2`IK)6i_NXZCnYKH_D*z`3e~@ z0-zcVM#KINTP)vtquDZzTZ9CJfEu*pfhmOYe?{_0Sx)5Zy+UX(+G+jxZ7^ew9IIGxW8Kz%$7K3Lk&iwv__>%-4Ou-D&0zT z(&o(UuJj=zZPI=}C(@``DMbnt0+ZDZr3y|O&9q4>NfMJQU`seAYE%%IU??d15Llxi zqU?b%1l}|Zh+1-UV-TrUT%$Gz-9>h1sc@V=wnm2I3^^e1{Z15vh!9v+y&GaSS`{m< zW6vO}B-e%yL>u8qL*}Y;L<(cy^v4J@>V>iuea|O#qoY-`0F()6H_I?NIUmKC&Lqd> zE=_z~8IRdc;^@b&PmglY6%gWu)>D96ouYqyB|RS;yff2}L85BY4_iWBCRgr3UpNVA zjIa0H`I>{!I-YjM+?5Zvl>L20aVTj-t z0=wdVF^eaPW7%m`4M!3Wbcau&&GKY5$$f`$Zxbi>1~?)R`!qX_(4pbe@9qSON3BNc z5P~dpubq2ZWiyF`mVmQ~y2rrxBHYlg!qD?1fgFQ)8@M)337&JPX`0bh%otQVGToS^ zFRt>QxmhI;5KviXShBzth?L?^*E9{a6Mp~QHwK#dt-mU%#FoKceoe z&~U)*w(9pSFoRNrs2gc}c6#>6((|5sz%$E*kbAWL~maaU6T7yyq)sl2nTuS%M|pEZbl48 zi{pD4Bq56I)R?a|0o&!e=fS@Z2QyxtSrw+PB8r6>Qf9&;ipNbpT}MJBd7zw2v0sHk ze|l&TuXZ)|wM8gVXADaJ(0zu0lYw3wmUS5Xb`An+BkfN7JV=jT_*nbO%U2s^6E%#5 zmYrDk5@L?j^<%qW=>C;QE${NFS)_#(84i?{qrmNAKrzB_BXsc9i0fR< z5tAk(1u!$y1>9f0@g5TnhOG3c(MiBECDAY1ryPYB)ELBpOjMs=e)>AN>KdK>$cP`j;+ z21|rA9)lAz0TWP~m`0+ex{8yLwS~2LHp&rL2TQM$(4cY0cT`xmJ_yE!Ia}D#u*j_~ z50+CMHPr#5$HLe0#8x)E3;-f4r4%w*1_ZYD{>`g%OS4&FwMn%Gt+du1&O<^I=GGxfD#ey*}iqWVwH=Rx$Y0wU3J+{^;E@v|)HH?WJ(QrE-ZUcZ(DzTUW z7tElwW~BkZDo_ehE5@SPow2H+bz3)xn6<8wM|fbO}H*}Yu^5glMVOG zVZg7uFx4i*adHE5#NUW;W|cHwW@5Q!u1ne*{~^CsRa~a3S|Sno>rraVL13z@7BG$n z#xQ$TLJ=^4Fv{ZFNuQBrI=!s4BQ7e6wuWrEO==W|d3DT!mL#xcEUr~WP6>)wgn(q_ z1RzyLp7(JJL6zs5OCfkf=@c|<@N|BERF^Jcsr!PuRvM+HlEd&34p}!I59VgMt^vqS zW5XWY7Q~0R{y~u3Qo2xU(0u7Hjsy@`IjKVqs34RxhbzToJtjMIHUuK{@KjcbSwc{v zTj9`4p{lLS0c?EN22oILf^$k_OsmR17UhQo!}&QdOH&w&@t}XaS-kA7%pfA7QnG=F zM9^&D!a~wW^F)_&1;r-mXFRfYIO0xbXJ-9!LtRL04^8W7tu&P9Pz)pSqX&Z?_{;mYUm!g*nH%R~SO3VD7S z8%;0@41xu;3<^;rD}@@s=&{;Hl^%QroMpS4YnD)Bsx*@Vz;>EW7ubbjmpRd(iOP&V zi$Dqq6j%$ED9LTMRb-<^V@jt}bEui}ETO_Qo1Alhv)A9q?GQ1Y!c-%anf3&VOasZ5 zPbuldBncZ+s7}HenpC4vU`Yk+U$-TBS_X4`JdJ?lVRjUYDLGVB)^RwZbYeSGK^PW< zF0)#wO~n*gAVSdvqfSo7kim)7wue}kCqxP0)lP*{h2UOz7HY4`kP-ljFt(X81jO*j zyHe}*MDX-5wSE#&3I#aY6Nf^JA_njago)l)*W_?#E@y6YA{AL0#96u3t`AWnYE++y zc1Tr&A;jht4g>=zBES7E;&7A#d#MC%PN?~b!$9?jc@2V|FbOXfqu!XMlNFqI#T^*M zm<}hI5SimBI$sfcVTuqsVLW;a&%$G!YaDUtBVSzwjP(=tq{C}{B#Gf@U)n9vGk6*O zX(0e(LxAN7hpNFWBK%9D^%Vr<1P>9!Pj~T7)_TxJXB3MpQMIvEGm&x>AOf@75j3Qt{V1-x|yt_-I;b?x^cDgzr z5fc!BJ87s&X6kkw3*d3X;$GSkAEkjv5rPFEMn-HjpqWy)t@{I3N&_lHAg2DX$nAiE zHDa@o9y>Wd+JpV=y}@YD0$Em!ipZvEWB%yk=IY)|i-v`z@f=Di)QG5n6~r-y3}DcW zeT7y^0Wz1^E+hg3FbX;?1_KHqF)GW50h7$3!=|kM#@893=-S1%rXcEP` zC`LMM6EL6!v`UvmGa|;e);iThBUHr5m>{5K1YirLBe>kg(aXuWItlqVw8F0YJTWO; zLsxfK5`RBgKbHz~WaI$<~fupH1-Kbrj+1}iHngrYG=D+bx-m+^0vcF#>BK)P_f`cU>- zs&UZR73GDr97qxZ;eKdrU}T1w9s7&xS_4>j zU~-Hw@dG*bij1m_=-gJJ+oF~WBS`;#>J(UE*nLU{uducDC-_$!)RM1Jx;U;QObOi{%kk`Ai+14TDO z4jB%D-}oK_25m$C3qzw_<#7xWKCAsRfFK63%;;6nwJ;P6@(zSn&P{T>ioVj4po9jw zi6{E`oT?)aD%eAuFaFtOBT|%X#$*%k#&=LSZyMa?}n4Q))?-wPf36YB; zw?x{2wOI}Ys)hnJvLh%`q}Ej9u-1eAXa{&;v)=B?2Tc3D5!_qjS)-J)jJB`^P(^sf zuy+n<0vuv>D~QNivP5}7S!OK(5EwwC6cJ`w(J*DjL|9=X1hN|u02L#!R%4@WqJWrf zkr_R*%!~%G%b*!Y8i^<`*cycjVU!mPtc`-wR9o_bwb4M>G*gS6(RMG-0h0zRWGED5 z#wbz*3J^glMWldiW^;|kphA^^ZUGXY0i{`4MiObRSRGw3l#5G|k#tUraSIC*J-UOj zf1Oe8^07xRmXhP--e2`QFkmk^;YB!Q6-+&0pYdf}WK>}m6@`Fsi<3S$vaqj!P%5xl ztUf5bxHutJLNq8MMZ0Ld2$_qQy;atjfaf8Yi|jrORDLt$C`C=iWU+5=b4*h8e*6ll zT(%kf-U1R?#S>JA^<=Y9D90kwTCk*CHkP06a1dEp`Cc6;L%1oV?;?hb_&p@R#z&oX zcGN22b{=D_yqgk)xP=9>C6pu8W@Lf2*ynC~&bx@G6N2li--zys0HG=h`XjD=;7#j{ z5du5bSfZPLums7Aj#N1g7Pv7{ocSk(9VJoN*#n5azkZ0pS{ro?ot4F--^)jW0ye*+ zLI_tF2SL0Xp!dNGMV*cWSQcIw+1R|rgMAaEJU5`|&xsW#&QO&a%&2|j92!+e#67?j zKFpUJETaq%r3ouM*i>>@@B=t@ni)opL%IQcBMX8NX`p9E7X3IB-X)=7LZKPvI=vVL z)}5G(!nQjJ&NUN*Msd?nBT-4JtYxKCDI3&?7MKyzmMTV^kGR~$D>HG~Cty%uEn5l* zY#PftgQ!ZJ0T?F>SO#WH4bOFPx~r{4OUhbCq`W9JGBOkeCjiU^v1TgJ@}QSxBLLH# zUYO3Zt(}d1g$h%>wUKXU%Jy<*YBn1)GgGAQbHyabfVKoC&fl(@W!4e_62QWIyWQB_ z8^ZdY8s*&EBO*w2`L?jCT2fe@8i|<*5`cyV1&qK7SZm}0wB0TScdc%y^jyIWg$SJ6 zf*A?`F32(!mf12R8)ODhNLku4-6JRaTU+@cTY6}=7rAQu2y zIc1kZrBE@k#uQZ>R94w=3MM8Vujn$fJ)Cof_yWf`oG{E}FXP0};312BDFMMrU>fEL zG#^*waDdjtx|LJTu|xZ-sLMn8Pr3B4hzW6W1VCUUU{y!&V)POQuU8bj>Ewgnau4-G zJVds2bp2?-5^|8A$*FUaK8Ru$8=$9pD9C8gly z$&_e0$drLT6%zp<%$egb5YF?kgqSOkJYqf2oMk3y?SW*O5$to1rLPsmsI?Y+Cf_b4 zl%mM7A1uHJ`8QU2vE*BfwQs~V>4|Sd2<4MJ8pSzzQYXWKUODEiR|Z~$IK_}LR`lRx z5s>HuirxsKg@iz%QFgkG3yaexwRsMR01C8n^DWGYb&6V{Er<$s6*e;~fM%*JL2uw7 z2q48H3V={_K1xX=K_gMk#3q_8LjjOms}K}Ik%L0E1?0#It+It8$VXg|%B^P7deli8 z&D^dOn33V$$Zqy+a~6$B8qMa^i~>-$(uqe=7**VwTn`ZekeJfMXrm4vo=OtT3ch&V zHVt+fkQY$UnAggo^S%5TiNUr}Frosi22{wYMj{yPUeEW|b!Q&u=8N1WfMByC-)G2K z17g8Qh$=AvFal5rsy%IHkIv4na4*kA9D`h8?l++@;Y-UksP zYNe>0-XM|Hh#g=6)QF%I6A@Gqb5(CZL70dw1IlS6O;RLOJ+~hqCPwzily~{eZN#{BC8cnRR@a_!^}QOj>YciEG!rpV?8DT15G3& zLZG9U)tDQVds4O6>G{TkkC_U@XihsTli~uqAJ;;fL^EI5gTI$VaO0~}-r-)m~Q1^~ry(+|A{Af)wYc8VVEuS+bm~B3BlbY3Qb6)<_B1 z1aX$3pYd>Ld!%gANR3WxW|d;lK-MjSEHO+5WR8H-BOxL{tErcg&gzZ9-3@yFeEReg zdUbWQyv9a>Qb3kVtF0y1w`Bk{2DMRzWnxaWQV0mF4YGpmoh>t@PFmP}$J&0%mh3J- zW{E6N+CWgGfQ@0DD$`7wQ_Zx~C~RH~timL1fMpu)k4D3y(KSKIW1S=#sH+8f3|vzVv`2JRi8-ks5{#FfgYw&Iq<}C< zYC07RT2h9AHg^ZZ2&HgHH!PI<;ZaS@Q7T}vgBptC}a(57>zJ}eL{303dTGK@Dr9| zPdV=u@?}ZGg$V(i@q-o4P9_E`3eXk?vVb6#RYV60s#6p<2Vy%5#31o6XGsnWUS^hA zO0_6yVin-}ipAhN$*dIL)cSj0tT=}8>-aHQ}-NQ1}zK@1#*PSWFQ zIAjcr1z}&9;~ZR_K1t5|RMv0Zqe1f9W!7^esunYMSh3FwUK$&#v z1UP!H?-T8QX)MR!@`@l7lqrNLIk!FqK_C`D_KQjpS&%IZ@^3*H$U6q@NXsgCdr>eq zz*^)NMYdaSKOd{bvUFU3+Jj=I63(_d!j$exRP)*!``Ih8GPV5dSNLOw(k5l6U#J5 z#gxp?tCU#P?j0mlRKapA^EI9A&wX##jtRxy3Yk~|A+Tk(#ArvO!sfut#J0>!FPBo~ zuJJgsOCckoNq_)z0#vLjyh8b5%Bi=;WMhY&+8k!)k6hHVX{Jb3ysE{xF9MO zE~vCp$|Q_>f?!VyOCv+^XIJ9i(OiV|}Thl*dS(+&l6ECRq^hguYK~^AJYcSiXuOtUFZj1`^MD z7Q7%3M7LN}N3xW}Fugduh(X~LC7pSp5=N);@YEy=P8q%Ct9%jj6Luk@s5nW1l7`8c zKNnU>P``SJQUc3$x(j2JutACHMK9H1abdmaKtxMDe+xKw&P znNb=n2{v_UcSQtbRW5UCOy*QrCDStG77OBXD^wa8Kq;hx@*J8PEvq3xsx+{+mJ=im zgjq&lbxWQ6_`^pYf9QeLol9@N_1zaYTJtF~lv_P9TjC(DxO%%y4O9;dKtRA+q1I^v z$juaYl1VF_A(P+~~0OV3;VgOMY;v5(eH8P?F)@XE+&?p<` zw$V|ukIZKKdiU1eXy59zjfkYm&JC2}RA$8tc@6+tvRep1`IkhYj5o({`SJ&^a46w( zQaOy5j*-7mV;8Uh@}M6nO13zMf1*BDaEE8~ysyw_C}sD^QNAW%U~dB%J{s)s6B{BE z`y_6o^+ItD_*Al%kUI8CK^}x@0tC)|47Lx(&{{brJ^0jdJb>MVLiCQ}#1yROEMyB9 zRHN)H4Cnyrz+rI{GU6v>SOlSO99(%JVc@U~Mv1LZEQ-PyNbbTP0w^2XaJ++JJDP5!xju`8y2m6-x1)T^b}B5u32>_%8*>& z%(Z_onyY%91iQ9Fu#2nk3=`zgM6S%euY`Wh*k&RbeD>P zS|wscU{xidB4ajUvaApkAOSOpsW$^7YbaAELE*SHGhV6`5CJoi9VfIXWL;f~JcogX zMyjcRTvNiBpeh*h(Xc($J@WAJ&Wzsc-(P;~-K+1t`@yZ=LuZa{*&z`!f#ouf9Vv=; zLV(gQ<0qw+jF2o45o%0KBT1T9Zx3IaO26{#^i+57jwjegrD@aHeMZgQZlk@>O}j0oBF_fEkTg3cX#j#H zLO_k0NwHE2QlJbJ0cxccC9PXBuIW#=*X&O)i_Dq&0%eUBBucA@V)>_EEEsX0$TOzyiVQ`8C*G! zp}ab%HZU5x2D4#|)Mo4)dnj~l>^SUluN}*~xTb*xr;wFh_v&1M<1_#{;0)s^!?pm1 zS|=7>gn$-`956Axsult`G=?#}a->p;c3E2gB%#sIxG*{{vN&=FEqx4yIbldk^5d#=vrJOE5LXWVBd7!}k7f=n*x9R=Uq)JXW17dujWZ~IC z2|@MjJ740u(H{#dsVnlsZWuaiE`(ld(e?+9EgF8Sg3x@+BZohEpqR`D zR;)=7PKZRQH9)Dacm(+*_)y|=3v#BUwo6qJqrd`%;mlF^O^EFmOQG|;N3Q!2cMqXA zo8=%Gr814HXrmB}j*P-$wDRT_k=I>=98tkYP6?y7Ln${`YngxOF4#VmrcG4SSq zmtz!EbKc>%eFEN4wtDbN~-*=S4*ATlasa+2{?t>4zWpw(k0>z)7?D^3l-gGTbsOI_6N;tB z&jSGvgH~D@Mulmr?ycu*@hkTB8C`ij)CvBBZ*(#CmFJwl$L!oP<}4{{R6107*naRAr++S_Y+*P8kyh1Blvy zEtuITlOQW9hNz875;njFbfQd}a?&u}g`~9zs>K?(0BE)rKk<>?)tlSbH@HwrCCC~; zF(?LPrHGZcfbxV108i#`VZDs#bj=GpV-fcBzDXYO4TNO zDV_}S;K2G`i=Gy`Nj{hZe1JmI4OUq=L;{F-y)W(Tih;+Kagr`IvNWZ7zf8qN`aT{1lg31T^Br&TqHcZ0W(=VP*@9Qb&=id)6fH@2>2KX;2<`J zLwrKmbqPihGPT#+qF}Nb69coLivtU?Q88YpvHrd>;6zcN80g7x5Qx7({1)Nl;gswT z)uo4Ap9?}#EVy7U7<(gS`ZU#IRHzY@Vq-ATWfG}Nx&UHC1qe9mlg(KvP#PF8 zHPCKCVqhx6ogNQ!u!Mwwssh6zD;yUbiQ-fP16yE|8l?dcG_p}()VQd(7A+`Us8ktk zlu6S@Gl53`>ZKp-Z{0_LsTp@%N9E?l5ncj?Fyg27w&EdXl6 z1+iA+RCx`Ipp@21BO)r)$f&uPo|#gMZEQo9?btlmXjIZbqnS}DgP;KwGblhp1DGlR zn-7#uRbnt<(5#Gx)R<0JwdP1qD=^G}Oq{b-*nRM!#`MC@dsj8&di2SeHWG~!KAIsW{e0#jA05z zI71aPVGNUC!^nHLte`)FvvYGW*R3nE;UA@ZA_T{I|1S?f2Ren8mr;t0wEzJi_@q8M z-FIbWCpQ#)$um4kpKE}y#!!_nZghYH68U2u9)$|V`BZWF60ezF5bPz!fkZ}X7o00~ ze06*pum`tE>V-T!d*3U^_ef;iOf+kVZ%|#d;7X!5ETPl=%56iowGlWHuOQa_A_##u z1EplSe1K@&)WGogFAYqSf|3uI1|o)tnu?U>GkK)|Fc=6>1Qc2*0EURuxWB$E&n zD+qA|N6M=un;lt%P=b)A`T{I6s(7Xfhb_#g(r~Ki;E4-zh$OoRLn9muY`ts0%2QBK zAF@EhR;fOy$`A%rzzl^&hTQW0keVrUnhJrG0;NzZhH--W*j-Nq&8A8XWPMV^sUAm2 zpn^0Vf527)8YvD(Fv?(%aX|otO0iNio7@2=MDal)--0Y2NrU- zW}69-OJh_ipjKKdr8R2Kivkr4dh{n>zwy)yCm#Id`73YS-@3JV;skDY)ard|Hu30! zUfCqHkQkNOGHV106_-h{ij`7|!D53OC)zV-?e4YV@Rm}DX-nx8QM1yj$_-;sM65B< zs!5F2W(a^rnWkYZ$N(6`w4ocbpu2z##*_h|r0UrLu}zoe<{vv{hi$ffw0n#ljYG}E zxfnZ^uZfyOhzcx{F6Nkdl6^Z|rWgaeAWrZydTE^(!m&(y^*(WqDdYYqJRy&i5fAp5 za2yLHyo0jk@AVB>QG=rMYKZBDIMpjS(z|`w=ZwbrMB#s_j3Iz^D=hG&j#J1%;82

sw|E}{jfOP2f^!->ETzCTJH^N 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 z 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

X^~F!IfdXzBIggF{5tx=-XiL16A{I zts4zZxfu z1>z*R5cZg;{WuT3xItXBp-;VzacjuHZiIvZ*X%uq4$vU}ZMfuh^#1;clL;6C$Fqv< z95;TD2@E&7DBRG%b+=@R_!0q$9T&7r%_NA$v#VX$88{PwtwOEG&W$lB5$n? z$rWgghM}&lk;57eU5K&~s0|vHs^*fRHfTo)Z0l48i~n+Q>*9}jpBCmsh8&877;fl6 zPXX{w2xvS|o$YmrT=1xG`6Y>5p3RYBxBcwQmX)?Hl*AMkB$=*GHGceHJVG0VkR(Fu z1d^aZDI2^ioU`l*0BHw6V@zjh2#SyF>~|ksoZjW~FUzKB@3J6c&k0M@v-h-=Y8g*WUHfAOGT4Unw6xF?)h%kM5f* z@~OFY{_<<(&UQc7Fc{ZUomRi2r)H)G+r!SmckjF7ukPzxC8NUwiJ$UmsP_ zm|YlGj5UC0VJT}UYpe~`#_HV2)}DTVl7#g66`*fM-MRmVZbZofhM}rqSi&Haj6f&QbNT3%Yca^>{-%UkoaEg?0iWqpSkrL6m_OP9xk z8(;a#Gizho9P!rr)<6Aw_s-_qr{BD`v{8Kd*|oW;#$1ORg_>^Xt#0x1mHv~bMp>f^ z1Z;=LF3Fn_L8IQ>>K6t7qn~^C&;P_dEwwzja8}q1mTr_g<@AyJ+s7U;dd6TY2T^)B zTE6zmb8kNV#B0yKeBtck+UgefE3DX*l~hKAK`WSQXPs`IWuUbi;t4}pb7??qkDCQi z6hTI$NR*abVKQX8UZE55FfENpr z(l-84LWq_mlxcrj2b`cJ;fvx!u044nLi0Ev^AyX^`^wca$W%37ulmhlYT7 z5vW5FB->-~Jo)F^2i&q1w+3`vobQpRq&Xo=3^j6>V)B&qev)DT|VdT0CGQRB^uL5F`8q(-?{nlI~HIK88Xp zPKhGN9W$3P;aou+gm%tNhEBT>>OYh+DJquuhfQ{Z>axxAMqK0tjix1`q z=_5%ci-_b>=WQ)zE0%aC33(>=jWEhYih#tjKOrDFFEED4PzoB;BsRcWCL{#yL6Xu2 ze(*=)iKyi0bO6G})%sQ?sW29#j~J6hyk#v0HF*0hASwi9R|Ntn(&%2K9VY@2Ym!1so|js2ra^1e z8Wo`c)COuJT$5oaOCDCn7y?U8(MU*u%0A|XO=Y;OxU8kFU}U(erDj>I)%EkHKE0mn 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

H|Mlv=$*Iz1XLN)j%;db$>7e3=2n*kL1MW!W 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�}N3TD-^|q_eREYfTZ_l>2MVLW=62ur~G#VxG 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 z { - decode_snapshot_from_bytes(&bytes) + parse_snapshot_payload_from_bytes(&bytes) .map_err(|e| format!("invalid agent snapshot: {e}"))?; } SnapshotFileKind::TeamJson | SnapshotFileKind::TeamPng => { diff --git a/desktop/src-tauri/src/commands/personas/card.rs b/desktop/src-tauri/src/commands/personas/card.rs new file mode 100644 index 0000000000..c516db1736 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card.rs @@ -0,0 +1,972 @@ +//! `mint_agent_card` / `save_agent_card` Tauri commands — Agent Trading Cards. +//! +//! Mints a collectible trading-card PNG for an agent via one OpenAI Responses +//! API call (designer model + native `image_generation` tool), then embeds the +//! agent's `buzz_agent_snapshot` manifest through the existing snapshot +//! encoder so the card IS an importable `.agent.png`. +//! +//! Boundary rules (agreed with Wren, buzz-agent-trading-cards thread): +//! - Snapshot construction/injection reuses `agent_snapshot.rs` — cards +//! inherit manifest-v1 behavior, exclusions, and size checks. No card-only +//! wire format exists. +//! - Memory inclusion is opt-in and shares the export flow's semantics: the +//! same three levels (`none`/`core`/`everything`), the same owner-gated +//! `get_agent_memory` fetch, and a memory source DERIVED from the resolved +//! instance (never caller-supplied), so cross-agent memory pairing is +//! structurally impossible. The default is `none`; the encoder still +//! rejects `none` + entries. +//! - The 10 MiB `.agent.png` ceiling is enforced on the FINAL bytes (after +//! resize + chunk injection) via `validate_snapshot_encode_size`. +//! - Round-trip verification decodes the final bytes and compares the logical +//! manifest before anything is returned to the frontend. +//! - The API key is resolved through the same env layering the agent runtime +//! uses (global config < persona < agent record) and never leaves Rust. +//! It is never logged. + +use base64::{engine::general_purpose::STANDARD, Engine as _}; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +use super::super::export_util::save_bytes_with_dialog; +use super::snapshot::{ + memory_entries_from_listing, parse_memory_level, resolve_from_lists, + validate_snapshot_encode_size, +}; +use crate::{ + app_state::AppState, + commands::engrams::get_agent_memory, + managed_agents::{ + agent_snapshot::{ + build_snapshot, decode_avatar_data_url, decode_snapshot_png, encode_snapshot_png, + extract_chunk_payload_png, MemoryLevel, + }, + agent_snapshot_envelope::{ + decrypt_envelope, encode_locked_snapshot_png, parse_chunk_payload, ChunkPayload, + }, + load_agent_definitions, load_global_agent_config, load_managed_agents, load_personas, + save_global_agent_config, validate_global_config, + }, +}; + +/// The Buzz card frame template — Tyler's gold-honeycomb base. Generation +/// input only: it never participates in the snapshot manifest, PNG chunk, +/// import decoder, or attachment validation. Embedded at compile time for +/// deterministic packaging (see `card_template_decodes` test). +const CARD_TEMPLATE_PNG: &[u8] = include_bytes!("../../../assets/card_template.png"); + +/// Designer model driving copy + art direction. +const DESIGNER_MODEL: &str = "gpt-5.6-sol"; +/// Image model invoked natively via the Responses `image_generation` tool. +const IMAGE_MODEL: &str = "gpt-image-2"; +/// Final card width in pixels (2:3 portrait → 1500x2250). +const CARD_WIDTH: u32 = 1500; +/// Longest edge for the real avatar inlined into an unlocked card's manifest. +/// Kind:0 pictures render small; 512px keeps the doubly-base64-encoded +/// manifest chunk modest next to the 1500-wide card body. +const MANIFEST_AVATAR_MAX_DIM: u32 = 512; +/// Upper bound for a fetched avatar (pre-resize input to the model). +const MAX_AVATAR_FETCH_BYTES: usize = 10 * 1024 * 1024; +/// One mint is a single long API call (~2–3 minutes observed). +const MINT_TIMEOUT_SECS: u64 = 600; + +/// Error prefix the frontend matches to route the user to provider settings +/// instead of showing a raw failure. +pub(crate) const NO_KEY_ERROR_PREFIX: &str = "NO_OPENAI_KEY:"; + +/// Wire shape returned by `mint_agent_card`. +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MintedCard { + /// Final `.agent.png` bytes (chunk-injected, round-trip verified), + /// base64-encoded for the IPC boundary. + pub card_png_base64: String, + /// Suggested filename, e.g. `eva.agent.png`. + pub file_name: String, + /// Designer commentary emitted alongside the image (may be empty). + pub designer_notes: String, + /// True when the embedded snapshot is NIP-44-encrypted to the + /// (owner, agent) pair — only their nsecs can import this card. + pub locked: bool, + /// How much memory is embedded in the card's snapshot ("none"/"core"/ + /// "everything"). The viewer's import disclosure depends on this. + pub memory_level: MemoryLevel, +} + +// ── Card archive ────────────────────────────────────────────────────────────── + +/// Sidecar metadata for one archived card PNG. Stored as `.json` next +/// to `.agent.png` in the cards dir — two plain files per mint, no +/// shared index to corrupt. Listing scans sidecars; a card whose PNG is +/// missing is skipped rather than failing the whole list. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ArchivedCardMeta { + /// Unique on-disk PNG file name within the cards dir. + pub stored_file_name: String, + /// Suggested save-as name, e.g. `eva.agent.png`. + pub file_name: String, + /// The id the card was minted for (instance pubkey or definition slug). + pub agent_id: String, + pub agent_name: String, + pub designer_notes: String, + pub locked: bool, + /// Memory embedded in this card's snapshot. Defaults to `None` when the + /// sidecar predates the field — every pre-field mint was minted with + /// `MemoryLevel::None` (it was structural), so the default is honest. + #[serde(default)] + pub memory_level: MemoryLevel, + /// ISO-8601 mint timestamp. + pub minted_at: String, + /// Small JPEG preview for gallery grids, base64. Populated by + /// `list_agent_cards` from the sidecar thumb file — never stored in the + /// JSON sidecar itself. + #[serde(default, skip_deserializing)] + pub thumb_jpeg_base64: Option, +} + +fn cards_dir(app: &AppHandle) -> Result { + let dir = crate::managed_agents::managed_agents_base_dir(app)?.join("cards"); + std::fs::create_dir_all(&dir).map_err(|e| format!("failed to create cards dir: {e}"))?; + Ok(dir) +} + +/// Persist a freshly minted card to the archive. Failures are surfaced to the +/// caller (which logs and continues) — an archive write must never fail a +/// mint the user already paid for. +fn archive_minted_card( + app: &AppHandle, + agent_id: &str, + agent_name: &str, + card: &MintedCard, + bytes: &[u8], +) -> Result { + let dir = cards_dir(app)?; + let stem = format!( + "{}-{}", + crate::util::slugify(agent_name, "agent", 50), + uuid::Uuid::new_v4() + ); + let stored_file_name = format!("{stem}.agent.png"); + let meta = ArchivedCardMeta { + stored_file_name: stored_file_name.clone(), + file_name: card.file_name.clone(), + agent_id: agent_id.to_string(), + agent_name: agent_name.to_string(), + designer_notes: card.designer_notes.clone(), + locked: card.locked, + memory_level: card.memory_level, + minted_at: crate::util::now_iso(), + thumb_jpeg_base64: None, + }; + // PNG first, sidecar second: a crash between the two leaves an orphaned + // PNG (invisible to the list), never a sidecar pointing at nothing. + std::fs::write(dir.join(&stored_file_name), bytes) + .map_err(|e| format!("failed to write archived card: {e}"))?; + let meta_json = serde_json::to_string_pretty(&meta) + .map_err(|e| format!("failed to serialize card metadata: {e}"))?; + std::fs::write(dir.join(format!("{stem}.json")), meta_json) + .map_err(|e| format!("failed to write card metadata: {e}"))?; + // Thumb last and best-effort: the gallery grid falls back to lazy + // full-card loading for a card whose thumb is missing. + if let Ok(thumb) = encode_card_thumb(bytes) { + let _ = std::fs::write(dir.join(format!("{stem}.thumb.jpg")), thumb); + } + Ok(meta) +} + +/// Downscale card PNG bytes to a small JPEG for gallery grids. The full card +/// is ~1500x2250 PNG (megabytes); shipping that per card over IPC just to +/// draw a grid tile is waste. +fn encode_card_thumb(bytes: &[u8]) -> Result, String> { + const THUMB_WIDTH: u32 = 300; + let img = image::load_from_memory(bytes).map_err(|e| format!("thumb decode: {e}"))?; + let scale = THUMB_WIDTH as f64 / img.width() as f64; + let thumb = img.resize( + THUMB_WIDTH, + (img.height() as f64 * scale).round().max(1.0) as u32, + image::imageops::FilterType::Triangle, + ); + let mut out = Vec::new(); + // JPEG has no alpha; cards are opaque, so flatten unconditionally. + let rgb = image::DynamicImage::ImageRgb8(thumb.to_rgb8()); + rgb.write_to( + &mut std::io::Cursor::new(&mut out), + image::ImageFormat::Jpeg, + ) + .map_err(|e| format!("thumb encode: {e}"))?; + Ok(out) +} + +/// Reject any archive file name that could escape the cards dir or name a +/// non-archive file. Archive names are generated by `archive_minted_card` +/// (slug + UUID), so a strict shape check loses nothing legitimate. +fn validate_archive_file_name(stored_file_name: &str) -> Result<(), String> { + let valid = stored_file_name.ends_with(".agent.png") + && !stored_file_name.contains(['/', '\\']) + && !stored_file_name.contains(".."); + if !valid { + return Err("Invalid archived card file name.".to_string()); + } + Ok(()) +} + +/// List all archived cards, newest first. +#[tauri::command] +pub fn list_agent_cards(app: AppHandle) -> Result, String> { + let dir = cards_dir(&app)?; + let entries = std::fs::read_dir(&dir).map_err(|e| format!("failed to read cards dir: {e}"))?; + let mut cards = Vec::new(); + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("json") { + continue; + } + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(meta) = serde_json::from_str::(&content) else { + // A malformed sidecar hides one card, never the archive. + eprintln!( + "buzz-desktop: card-archive: skipping malformed sidecar {}", + path.display() + ); + continue; + }; + let mut meta = meta; + if validate_archive_file_name(&meta.stored_file_name).is_ok() + && dir.join(&meta.stored_file_name).is_file() + { + // Attach the pre-rendered grid thumb when present (best-effort). + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + meta.thumb_jpeg_base64 = std::fs::read(dir.join(format!("{stem}.thumb.jpg"))) + .ok() + .map(|b| STANDARD.encode(&b)); + } + cards.push(meta); + } + } + // ISO-8601 sorts lexicographically; newest first. + cards.sort_by(|a, b| b.minted_at.cmp(&a.minted_at)); + Ok(cards) +} + +/// Load one archived card's PNG bytes as base64, keyed by its stored file +/// name (as returned by `list_agent_cards`). +#[tauri::command] +pub fn load_agent_card(stored_file_name: String, app: AppHandle) -> Result { + validate_archive_file_name(&stored_file_name)?; + let bytes = std::fs::read(cards_dir(&app)?.join(&stored_file_name)) + .map_err(|e| format!("failed to read archived card: {e}"))?; + Ok(STANDARD.encode(&bytes)) +} + +// ── Key resolution ──────────────────────────────────────────────────────────── + +/// Pure layering: global env < persona env < agent record env, then the +/// process environment as a development fallback. Returns the first +/// non-empty value for `key`. +pub(crate) fn resolve_env_from_layers( + key: &str, + global_env: &std::collections::BTreeMap, + persona_env: &std::collections::BTreeMap, + record_env: &std::collections::BTreeMap, + process_value: Option, +) -> Option { + for layer in [record_env, persona_env, global_env] { + if let Some(v) = layer.get(key) { + let v = v.trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + process_value.filter(|k| !k.trim().is_empty()) +} + +/// The Responses endpoint to post mints to. `OPENAI_BASE_URL` (same env +/// layering as the key) overrides the default host, supporting endpoints and +/// proxies that speak the OpenAI Responses shape with Bearer auth. Azure +/// OpenAI is NOT covered by this override alone — it uses its own URL scheme +/// and `api-key` auth header, which would need a real driver. +pub(crate) fn responses_url(base_url: Option) -> String { + let base = base_url.unwrap_or_else(|| "https://api.openai.com/v1".to_string()); + format!("{}/responses", base.trim_end_matches('/')) +} + +// ── Prompt construction ─────────────────────────────────────────────────────── + +/// Build the designer instructions. Pure so tests can pin the contract: +/// style-match-the-avatar is DEFAULT behavior; owner directions (art AND +/// card text) take primacy over those style defaults, but never over the +/// fixed contract (frame identity, geometry, text fidelity). +pub(crate) fn build_card_instructions( + agent_name: &str, + persona_notes: &str, + style_notes: &str, +) -> String { + let owner_directions = if style_notes.trim().is_empty() { + String::new() + } else { + format!( + "\nOWNER'S DIRECTIONS — these override the default art-style and copy guidance \ + below wherever they conflict (they cannot change the frame, layout, or \ + text-fidelity requirements). The owner may direct the art, the card text \ + (type line, ability, flavor), or both:\n{style_notes}\n" + ) + }; + format!( + r#"You are designing one premium collectible trading card for the Buzz agent "{agent_name}". + +Input image 1 is the official Buzz card frame template (gold honeycomb border, dark interior, name banner top, hex badge top-right, text box lower third). Input image 2 is the agent's avatar — study its exact art style: medium, pixel grid if any, palette, shading, background motifs. + +Persona notes for the card copy: +{persona_notes} +{owner_directions} +First, write professional trading-card copy at Magic: The Gathering editorial quality: +- a type line (e.g. "Legendary Agent — Team Lead"), +- ONE keyworded ability: short bolded ability name + one sentence of crisp rules text written like real MTG rules (present tense, precise, no fluff), +- ONE italic flavor-text line, evocative and short, the kind that gets quoted. +Where the owner's directions specify card text, use their wording within the 220-character text-box limit below (edited only for spelling; if their text exceeds the limit, condense it minimally while keeping their words and intent); invent copy only for the parts they left open. +Keep total text-box copy under 220 characters so it renders cleanly. + +Then generate the finished card with the image tool, exactly 1024x1536 portrait: +- The frame must follow input image 1 faithfully: same gold honeycomb border, same layout, honey drip detail. +- Default art style: match input image 2's art style EXACTLY — same medium, same pixel density if pixel art, same palette, same background honeycomb-lattice sky. It must look like the same artist drew a larger scene: the character in a confident pose, conjuring glowing golden hexagons. The owner's directions above override any of this default styling where they conflict. +- Name banner: "{agent_name}" plus the type line beneath it in smaller type. +- Text box: the ability name in bold, rules text in regular, then the flavor line in italics, cleanly typeset like a real MTG card — professional kerning, no misspellings, hyphenate nothing. +- Top-right hex badge: one small emblem of your choice, no text. +Render all text with perfect fidelity."# + ) +} + +/// Encode raw image bytes as a `data:image/png;base64,` URL, downscaling to +/// `max_dim` on the longest edge so request payloads stay small. +fn image_data_url(bytes: &[u8], max_dim: u32) -> Result { + Ok(format!( + "data:image/png;base64,{}", + STANDARD.encode(png_bytes_resized(bytes, max_dim)?) + )) +} + +/// Re-encode an image as PNG, downscaling so neither side exceeds `max_dim`. +fn png_bytes_resized(bytes: &[u8], max_dim: u32) -> Result, String> { + let img = image::load_from_memory(bytes).map_err(|e| format!("Failed to decode image: {e}"))?; + let img = if img.width().max(img.height()) > max_dim { + img.resize(max_dim, max_dim, image::imageops::FilterType::Lanczos3) + } else { + img + }; + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode image: {e}"))?; + Ok(png) +} + +// ── Response parsing ────────────────────────────────────────────────────────── + +/// Extract the generated image (base64) and any designer text from a +/// Responses API payload. Pure for testability. +pub(crate) fn extract_card_output(resp: &serde_json::Value) -> Result<(String, String), String> { + let output = resp + .get("output") + .and_then(|o| o.as_array()) + .ok_or_else(|| "Responses payload has no output array".to_string())?; + + let mut image_b64 = None; + let mut notes = Vec::new(); + for item in output { + match item.get("type").and_then(|t| t.as_str()) { + Some("image_generation_call") => { + if let Some(result) = item.get("result").and_then(|r| r.as_str()) { + image_b64 = Some(result.to_string()); + } + } + Some("message") => { + if let Some(content) = item.get("content").and_then(|c| c.as_array()) { + for c in content { + if c.get("type").and_then(|t| t.as_str()) == Some("output_text") { + if let Some(text) = c.get("text").and_then(|t| t.as_str()) { + notes.push(text.to_string()); + } + } + } + } + } + _ => {} + } + } + + let image_b64 = image_b64.ok_or_else(|| { + let types: Vec<&str> = output + .iter() + .filter_map(|i| i.get("type").and_then(|t| t.as_str())) + .collect(); + format!("No image in Responses output (item types: {types:?})") + })?; + Ok((image_b64, notes.join("\n"))) +} + +// ── Commands ────────────────────────────────────────────────────────────────── + +/// Save an `OPENAI_API_KEY` into the global Agent Defaults env for card +/// minting — a narrow seam with deliberately different semantics from the +/// general `set_global_agent_config`: +/// +/// - **No agent restarts.** The general command stops/restarts every running +/// local agent whose effective env changes, because agent env is baked at +/// spawn time. The mint command re-reads the config from disk on every +/// mint, so minting needs no restart — and a card setup must never disrupt +/// running agents as a side effect. Agents pick the key up naturally on +/// their next (re)start. +/// - **Read-modify-write of the latest on-disk config.** The config is +/// re-read immediately before the single-key insert + write (under the +/// managed-agents store lock, which serializes it against the other card +/// and agent-store commands), so a settings save that landed after this +/// dialog opened is not clobbered with a stale dialog-open snapshot. +/// (The general settings editor performs its own whole-config write; as +/// today, the last writer wins between the two surfaces.) +/// +/// Standard global-config validation still applies (POSIX key shape, +/// reserved-key reject, size caps) — this is not a validation bypass. +#[tauri::command] +pub fn card_mint_save_openai_key( + key: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result<(), String> { + let key = key.trim().to_string(); + if key.is_empty() { + return Err("API key cannot be empty.".to_string()); + } + + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let mut config = load_global_agent_config(&app)?; + config.env_vars.insert("OPENAI_API_KEY".to_string(), key); + validate_global_config(&config)?; + save_global_agent_config(&app, &config) +} + +/// Report whether an OpenAI key would resolve for a card mint of agent `id`, +/// using exactly the same env layering as `mint_agent_card`. Lets the mint +/// dialog offer inline key setup BEFORE the user commits to a mint, instead +/// of failing after the fact. Never returns the key itself. +#[tauri::command] +pub fn card_mint_key_status( + id: String, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, _) = resolve_from_lists(&id, &instances, &definitions)?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + Ok(resolve_env_from_layers( + "OPENAI_API_KEY", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .is_some()) +} + +/// Mint a trading card for the agent identified by `id` (instance pubkey, +/// instance slug, or definition slug — same resolution as snapshot export). +/// +/// When `lock` is true the embedded manifest is NIP-44-encrypted to the +/// (owner, agent) pair per the locked-envelope contract — this requires a +/// linked agent instance (the second key endpoint); bare definitions cannot +/// be locked. +/// +/// When `memory_level` is `"core"` or `"everything"`, the owner's decrypted +/// memory for the agent is embedded in the manifest — same levels and fetch +/// as snapshot export. The memory source is always the resolved instance +/// itself (derived, never caller-supplied), so it requires a linked instance; +/// bare definitions can only mint `"none"` (the default). +/// +/// Returns the final, chunk-injected, round-trip-verified `.agent.png` bytes. +/// Reroll = call again; the command holds no session state. +#[tauri::command] +pub async fn mint_agent_card( + id: String, + style_notes: Option, + lock: Option, + memory_level: Option, + app: AppHandle, + state: State<'_, AppState>, +) -> Result { + let lock = lock.unwrap_or(false); + let memory_level = parse_memory_level(memory_level.as_deref().unwrap_or(""))?; + // ── Resolve the record + API key under lock ────────────────────────────── + let (mut record, is_definition, api_key, base_url) = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + + let instances = load_managed_agents(&app)?; + let definitions = load_agent_definitions(&app)?; + let (record, is_definition) = + resolve_from_lists(&id, &instances, &definitions).map(|(r, d)| (r.clone(), d))?; + + let global = load_global_agent_config(&app).unwrap_or_default(); + let personas = load_personas(&app).unwrap_or_default(); + let persona_env = record + .persona_id + .as_deref() + .and_then(|pid| personas.iter().find(|p| p.id == pid)) + .map(|p| p.env_vars.clone()) + .unwrap_or_default(); + + let api_key = resolve_env_from_layers( + "OPENAI_API_KEY", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_API_KEY").ok(), + ) + .ok_or_else(|| { + format!( + "{NO_KEY_ERROR_PREFIX} No OPENAI_API_KEY found. Add one in the agent's \ + environment variables or global agent settings to mint cards." + ) + })?; + let base_url = resolve_env_from_layers( + "OPENAI_BASE_URL", + &global.env_vars, + &persona_env, + &record.env_vars, + std::env::var("OPENAI_BASE_URL").ok(), + ); + + (record, is_definition, api_key, base_url) + }; + + // ── Locking needs its two exact key endpoints up front, BEFORE the + // API spend: the owner identity secret and the agent instance pubkey. + let lock_keys = if lock { + if is_definition { + return Err( + "Locked cards need a linked agent instance — this persona has never been \ + started, so there is no agent key to lock to." + .to_string(), + ); + } + let owner_keys = state.signing_keys()?; + // Same canonical check the envelope decoder enforces (incl. curve + // validation) — a non-point record pubkey must fail BEFORE the API + // spend, not at post-mint encryption. + let agent_pubkey = crate::managed_agents::agent_snapshot_envelope::parse_canonical_pubkey( + "agentPubkey", + &record.pubkey, + ) + .map_err(|_| { + "Agent record has an invalid pubkey (not a canonical x-only key).".to_string() + })?; + if owner_keys.public_key() == agent_pubkey { + return Err("Cannot lock a card to itself: owner and agent keys match.".to_string()); + } + Some((owner_keys, agent_pubkey)) + } else { + None + }; + + // ── Memory needs a keyed instance, resolved up front BEFORE the API + // spend — the memory source is always the resolved instance itself + // (derived, never caller-supplied), so cross-agent pairing cannot be + // expressed. A failed fetch fails the mint here, not after payment. + let memory_entries = if memory_level == MemoryLevel::None { + Vec::new() + } else { + if is_definition { + return Err( + "Cards with memory need a linked agent instance — this persona has never \ + been started, so there is no agent memory to include." + .to_string(), + ); + } + let listing = get_agent_memory(record.pubkey.clone(), app.clone(), state.clone()).await?; + memory_entries_from_listing(listing, memory_level) + }; + + let display_name = record + .display_name + .clone() + .unwrap_or_else(|| record.name.clone()); + + // ── Prefer the agent's own kind:0 profile picture ──────────────────────── + // The record's `avatar_url` is a stale presentation snapshot: with + // agent-managed profiles the agent updates its own kind:0 `picture` and + // desktop reconciliation is disabled (`agent_settings.rs`), so the relay + // profile — not the local record — is the live source of truth for how the + // agent looks. Definitions have no keypair and thus no kind:0; they keep + // the record's avatar. A relay error fails the mint here, BEFORE the API + // spend (same fail-early rule as the key/memory guards above) — minting + // with the wrong face wastes the spend it was supposed to protect. + if !is_definition { + let relay_url = crate::relay::effective_agent_relay_url( + &record.relay_url, + &crate::relay::relay_ws_url_with_override(&state), + ); + let profile = crate::relay::query_agent_profile(&state, &relay_url, &record.pubkey) + .await + .map_err(|e| format!("Could not read the agent's profile for its avatar: {e}"))?; + record.avatar_url = preferred_avatar_url( + profile.and_then(|info| info.picture), + record.avatar_url.take(), + ); + } + + // ── Resolve avatar bytes (data URL, else fetch) ────────────────────────── + let avatar_bytes = match record.avatar_url.as_deref() { + Some(url) if url.starts_with("data:") => decode_avatar_data_url(url) + .ok_or_else(|| "Agent avatar data URL could not be decoded.".to_string())?, + Some(url) if url.starts_with("http://") || url.starts_with("https://") => { + // Relay-hosted avatars (kind:0 pictures under the relay's /media/) + // may require Blossom get-auth (`require_media_get_auth`). Mint the + // header ONLY for same-origin URLs so the token never leaves the + // relay (same contract as `media_download.rs`). + let relay_base = crate::relay::relay_api_base_url_with_override(&state); + let auth = is_same_origin(url, &relay_base) + .then(|| crate::commands::media::mint_media_get_auth(&state, &relay_base)) + .flatten(); + fetch_avatar(url, auth.as_deref()).await? + } + _ => { + return Err( + "Agent has no avatar image. Set an avatar before minting a card.".to_string(), + ) + } + }; + + // ── Build the manifest now (with any requested memory) so a broken agent + // fails before we spend minutes on the API call. ─────────────────────── + let manifest_avatar = manifest_avatar_bytes( + lock_keys.is_some(), + &avatar_bytes, + record.avatar_url.as_deref(), + )?; + let snapshot = build_snapshot( + &record, + memory_level, + memory_entries, + manifest_avatar.as_deref(), + ); + + // ── One Responses API call ─────────────────────────────────────────────── + // For locked mints, prove the manifest (including any embedded memory) + // fits the NIP-44 plaintext cap BEFORE spending minutes on the API call + // (same fail-early rule as the memory guard above). + if lock_keys.is_some() { + let json_len = + crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot)?.len(); + if json_len > buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX { + let hint = if memory_level == MemoryLevel::None { + "Reduce the avatar size or mint an unlocked card." + } else { + "Include less memory, reduce the avatar size, or mint an unlocked card." + }; + return Err(format!( + "Agent manifest is too large to lock ({json_len} bytes; the encrypted \ + format caps at {}). {hint}", + buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX + )); + } + } + let instructions = build_card_instructions( + &display_name, + snapshot.definition.system_prompt.as_deref().unwrap_or(""), + style_notes.as_deref().unwrap_or(""), + ); + let body = serde_json::json!({ + "model": DESIGNER_MODEL, + "reasoning": {"effort": "high"}, + "instructions": "You are a senior TCG card designer and MTG rules editor.", + "input": [{ + "role": "user", + "content": [ + {"type": "input_text", "text": instructions}, + {"type": "input_image", "image_url": image_data_url(CARD_TEMPLATE_PNG, 1024)?}, + {"type": "input_image", "image_url": image_data_url(&avatar_bytes, 1024)?}, + ], + }], + "tools": [{ + "type": "image_generation", + "model": IMAGE_MODEL, + "quality": "high", + "size": "1024x1536", + "output_format": "png", + }], + "tool_choice": "required", + }); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(MINT_TIMEOUT_SECS)) + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let resp = client + .post(responses_url(base_url)) + .bearer_auth(&api_key) + .json(&body) + .send() + .await + .map_err(|e| format!("Card mint request failed: {e}"))?; + + let status = resp.status(); + let payload: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("Card mint response was not JSON: {e}"))?; + if !status.is_success() { + // Never echo the request (it embeds nothing secret, but keep the + // failure surface small); the OpenAI error body is safe to surface. + let detail = payload + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + .unwrap_or("unknown error"); + return Err(format!("Card mint failed (HTTP {status}): {detail}")); + } + + let (image_b64, designer_notes) = extract_card_output(&payload)?; + let raw_card = STANDARD + .decode(image_b64.as_bytes()) + .map_err(|e| format!("Generated image was not valid base64: {e}"))?; + + // ── Resize to 1500-wide, inject chunk via the existing encoder ────────── + let card_img = image::load_from_memory(&raw_card) + .map_err(|e| format!("Generated image could not be decoded: {e}"))?; + let scale = CARD_WIDTH as f64 / card_img.width() as f64; + let card_img = card_img.resize( + CARD_WIDTH, + (card_img.height() as f64 * scale).round() as u32, + image::imageops::FilterType::Lanczos3, + ); + let mut card_png = Vec::new(); + card_img + .write_to( + &mut std::io::Cursor::new(&mut card_png), + image::ImageFormat::Png, + ) + .map_err(|e| format!("Failed to encode card PNG: {e}"))?; + + let final_bytes = match &lock_keys { + None => encode_snapshot_png(&snapshot, Some(&card_png)) + .map_err(|e| format!("Failed to embed agent snapshot in card: {e}"))?, + Some((owner_keys, agent_pubkey)) => { + encode_locked_snapshot_png(&snapshot, owner_keys, agent_pubkey, Some(&card_png)) + .map_err(|e| format!("Failed to embed locked agent snapshot in card: {e}"))? + } + }; + + // ── Verify: size ceiling + round-trip on the FINAL bytes ──────────────── + // Locked cards: extract the actual chunk, parse the envelope, decrypt + // with the owner key, then compare the logical manifest (ciphertext is + // nondeterministic — never compare bytes). + validate_snapshot_encode_size(final_bytes.len(), true)?; + let decoded = match &lock_keys { + None => decode_snapshot_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?, + Some((owner_keys, _)) => { + let payload = extract_chunk_payload_png(&final_bytes) + .map_err(|e| format!("Card failed round-trip verification: {e}"))?; + match parse_chunk_payload(&payload) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + { + ChunkPayload::Locked(envelope) => { + decrypt_envelope(&envelope, owner_keys.secret_key()) + .map_err(|e| format!("Card failed round-trip verification: {e}"))? + } + ChunkPayload::Plain(_) => { + return Err( + "Card round-trip verification failed: expected a locked envelope." + .to_string(), + ) + } + } + } + }; + if decoded != snapshot { + return Err("Card round-trip verification failed: manifest mismatch.".to_string()); + } + + let slug = crate::util::slugify(&display_name, "agent", 50); + let minted = MintedCard { + card_png_base64: STANDARD.encode(&final_bytes), + file_name: format!("{slug}.agent.png"), + designer_notes, + locked: lock_keys.is_some(), + memory_level, + }; + + // Archive best-effort: the mint is already paid for and verified, so a + // failed archive write logs and continues — it never fails the mint. + if let Err(e) = archive_minted_card(&app, &id, &display_name, &minted, &final_bytes) { + eprintln!("buzz-desktop: card-archive: failed to archive minted card: {e}"); + } + + Ok(minted) +} + +/// The avatar the mint should use: the agent's kind:0 `picture` when one is +/// published and non-blank, else the local record's `avatar_url`. +/// +/// Pure so the precedence is unit-testable without a relay: a blank or +/// whitespace-only `picture` must NOT shadow a real record avatar. +fn preferred_avatar_url( + kind0_picture: Option, + record_avatar_url: Option, +) -> Option { + kind0_picture + .filter(|p| !p.trim().is_empty()) + .or(record_avatar_url) +} + +/// The avatar bytes the card manifest should inline. +/// +/// Unlocked cards must carry the agent's REAL avatar inline: the PNG body is +/// the generated card artwork, and the importer only adopts the body as the +/// avatar when the manifest carries no inline bytes (`import.rs`) — without +/// these bytes an imported agent would wear the card as its face. Downscaled +/// to [`MANIFEST_AVATAR_MAX_DIM`] so the manifest tEXt chunk stays small. +/// +/// Locked cards keep the data-URL-only behavior: the whole manifest must fit +/// the NIP-44 plaintext cap (65 KB), which cannot carry inline pixels, and a +/// locked envelope never reaches the import body override anyway. +fn manifest_avatar_bytes( + locked: bool, + avatar_bytes: &[u8], + record_avatar_url: Option<&str>, +) -> Result>, String> { + if locked { + return Ok(decode_avatar_data_url(record_avatar_url.unwrap_or(""))); + } + png_bytes_resized(avatar_bytes, MANIFEST_AVATAR_MAX_DIM) + .map(Some) + .map_err(|e| format!("Failed to inline the agent avatar into the card manifest: {e}")) +} + +/// True when `url` shares an origin (scheme, host, port) with `relay_base`. +/// +/// Gate for attaching the minted media get-auth header — the token must never +/// be sent to a non-relay origin (same contract as `validate_download_url` in +/// `media_download.rs`, but non-fatal: a foreign origin just fetches +/// unauthenticated instead of failing the mint). +fn is_same_origin(url: &str, relay_base: &str) -> bool { + match (url::Url::parse(url), url::Url::parse(relay_base)) { + (Ok(u), Ok(b)) => u.origin() == b.origin(), + _ => false, + } +} + +/// Fetch an avatar over HTTP with a hard size cap. +/// +/// `auth` is an optional pre-minted Blossom get-auth header value, attached +/// verbatim — the caller is responsible for only supplying it for +/// relay-origin URLs. Redirects are not followed when auth is present +/// (redirect-hop guard, same rule as `media_download.rs`). +/// +/// The cap bounds network and memory, not just the final buffer: the +/// Content-Length header is checked before any body bytes are read, and the +/// body is streamed with a running count so a missing or dishonest header +/// still cannot exceed the cap (same contract as `media_download.rs`). +async fn fetch_avatar(url: &str, auth: Option<&str>) -> Result, String> { + use futures_util::StreamExt; + + let mut builder = reqwest::Client::builder().timeout(std::time::Duration::from_secs(30)); + if auth.is_some() { + // Never let a relay 3xx forward the auth header across origins. + builder = builder.redirect(reqwest::redirect::Policy::none()); + } + let client = builder + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}"))?; + let mut req = client.get(url); + if let Some(auth) = auth { + req = req.header("authorization", auth); + } + let resp = req + .send() + .await + .map_err(|e| format!("Failed to fetch agent avatar: {e}"))?; + if !resp.status().is_success() { + return Err(format!("Avatar fetch failed: HTTP {}", resp.status())); + } + + if let Some(content_length) = resp.content_length() { + if content_length > MAX_AVATAR_FETCH_BYTES as u64 { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + } + + let mut bytes = Vec::new(); + let mut stream = resp.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Failed to read avatar bytes: {e}"))?; + append_within_avatar_cap(&mut bytes, &chunk)?; + } + Ok(bytes) +} + +/// Append a body chunk to the avatar buffer, rejecting before the append if +/// the total would cross `MAX_AVATAR_FETCH_BYTES`. Split out so the cap +/// boundary is unit-testable without an HTTP server. +fn append_within_avatar_cap(buf: &mut Vec, chunk: &[u8]) -> Result<(), String> { + if buf.len() + chunk.len() > MAX_AVATAR_FETCH_BYTES { + return Err("Agent avatar is too large to use as card input.".to_string()); + } + buf.extend_from_slice(chunk); + Ok(()) +} + +/// Save previously minted card bytes to disk via the OS save dialog. +/// +/// Re-validates the bytes (chunk parses as a plain manifest or a +/// structurally valid locked envelope, size within the import ceiling) so a +/// corrupted preview can never be written as a `.agent.png`. No decryption +/// happens here — the mint already round-trip-verified with the real key. +#[tauri::command] +pub async fn save_agent_card( + card_png_base64: String, + file_name: String, + app: AppHandle, +) -> Result { + let bytes = STANDARD + .decode(card_png_base64.as_bytes()) + .map_err(|e| format!("Card bytes were not valid base64: {e}"))?; + validate_snapshot_encode_size(bytes.len(), true)?; + let payload = extract_chunk_payload_png(&bytes) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + parse_chunk_payload(&payload) + .map_err(|e| format!("Refusing to save: card failed snapshot validation: {e}"))?; + + let safe_name = if file_name.ends_with(".agent.png") && !file_name.contains(['/', '\\']) { + file_name + } else { + "card.agent.png".to_string() + }; + save_bytes_with_dialog(&app, &safe_name, "Agent card", &["png"], &bytes).await +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests; diff --git a/desktop/src-tauri/src/commands/personas/card/tests.rs b/desktop/src-tauri/src/commands/personas/card/tests.rs new file mode 100644 index 0000000000..ca69c43866 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/card/tests.rs @@ -0,0 +1,324 @@ +//! Unit tests for `card.rs` — split into a child module file so the parent +//! stays under the 1000-line gate (same layout as `snapshot/tests.rs`). + +use super::*; +use std::collections::BTreeMap; + +#[test] +fn archive_file_name_validation_rejects_escapes() { + assert!(validate_archive_file_name("eva-1234.agent.png").is_ok()); + for bad in [ + "../escape.agent.png", + "sub/dir.agent.png", + "sub\\dir.agent.png", + "not-a-card.png", + "plain.json", + "", + ] { + assert!( + validate_archive_file_name(bad).is_err(), + "expected rejection: {bad:?}" + ); + } +} + +#[test] +fn card_template_decodes_with_expected_shape() { + // The embedded template is generation input only, but a corrupt or + // accidentally swapped asset should fail the build's test gate, not a + // user's first mint. + let img = image::load_from_memory(CARD_TEMPLATE_PNG).expect("template must decode"); + // 2:3-ish portrait frame. + assert!(img.height() > img.width(), "template must be portrait"); + assert!(img.width() >= 512, "template unexpectedly small"); +} + +#[test] +fn key_resolution_layering_record_wins() { + let mut global = BTreeMap::new(); + global.insert("OPENAI_API_KEY".to_string(), "global".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), "record".to_string()); + + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("record") + ); + record.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("persona") + ); + persona.clear(); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).as_deref(), + Some("global") + ); + global.clear(); + assert_eq!( + resolve_env_from_layers( + "OPENAI_API_KEY", + &global, + &persona, + &record, + Some("process".to_string()) + ) + .as_deref(), + Some("process") + ); + assert!(resolve_env_from_layers("OPENAI_API_KEY", &global, &persona, &record, None).is_none()); +} + +#[test] +fn key_resolution_skips_blank_values() { + let mut record = BTreeMap::new(); + record.insert("OPENAI_API_KEY".to_string(), " ".to_string()); + let mut persona = BTreeMap::new(); + persona.insert("OPENAI_API_KEY".to_string(), "persona".to_string()); + assert_eq!( + resolve_env_from_layers("OPENAI_API_KEY", &BTreeMap::new(), &persona, &record, None) + .as_deref(), + Some("persona") + ); +} + +#[test] +fn responses_url_default_and_override() { + assert_eq!(responses_url(None), "https://api.openai.com/v1/responses"); + // Trailing slashes must not produce a double-slash path. + assert_eq!( + responses_url(Some("https://proxy.example/v1/".to_string())), + "https://proxy.example/v1/responses" + ); + assert_eq!( + responses_url(Some("https://proxy.example/v1".to_string())), + "https://proxy.example/v1/responses" + ); +} + +#[test] +fn instructions_pin_style_match_default_and_owner_primacy() { + let base = build_card_instructions("Eva", "leads the team", ""); + assert!(base.contains("match input image 2's art style EXACTLY")); + assert!(base.contains("\"Eva\"")); + assert!(!base.contains("OWNER'S DIRECTIONS")); + + let directed = build_card_instructions("Eva", "leads the team", "make it stormy"); + // Owner directions take primacy over style defaults... + assert!(directed.contains("OWNER'S DIRECTIONS")); + assert!(directed.contains("make it stormy")); + assert!(directed.contains("override the default art-style and copy guidance")); + // ...but the fixed contract survives: frame, style anchor (as an + // overridable default), and text-fidelity requirements stay present. + assert!(directed.contains("match input image 2's art style EXACTLY")); + assert!(directed.contains("cannot change the frame, layout, or")); + assert!(directed.contains("Render all text with perfect fidelity")); + // Card-text direction is an explicitly named capability, and the + // owner-wording rule acknowledges the fixed 220-char text-box limit + // (no mutually impossible "verbatim" vs "under 220 chars" pair). + assert!(directed.contains("card text")); + assert!(directed.contains("use their wording within the 220-character text-box limit")); +} + +#[test] +fn extract_card_output_happy_path_and_missing_image() { + let ok = serde_json::json!({ + "output": [ + {"type": "reasoning"}, + {"type": "image_generation_call", "result": "aW1n"}, + {"type": "message", "content": [ + {"type": "output_text", "text": "notes here"} + ]} + ] + }); + let (img, notes) = extract_card_output(&ok).unwrap(); + assert_eq!(img, "aW1n"); + assert_eq!(notes, "notes here"); + + let missing = serde_json::json!({"output": [{"type": "message", "content": []}]}); + let err = extract_card_output(&missing).unwrap_err(); + assert!(err.contains("No image"), "{err}"); + + let no_output = serde_json::json!({}); + assert!(extract_card_output(&no_output).is_err()); +} + +#[test] +fn kind0_picture_wins_over_record_avatar_unless_blank() { + let some = |s: &str| Some(s.to_string()); + // Published picture wins. + assert_eq!( + preferred_avatar_url(some("https://relay/media/k0.png"), some("data:image/png;x")), + some("https://relay/media/k0.png") + ); + // No profile / no picture: record avatar survives. + assert_eq!( + preferred_avatar_url(None, some("data:image/png;x")), + some("data:image/png;x") + ); + // Blank or whitespace picture must not shadow a real avatar. + assert_eq!( + preferred_avatar_url(some(""), some("data:image/png;x")), + some("data:image/png;x") + ); + assert_eq!( + preferred_avatar_url(some(" "), some("data:image/png;x")), + some("data:image/png;x") + ); + // Nothing anywhere: None (caller surfaces the "no avatar" error). + assert_eq!(preferred_avatar_url(None, None), None); +} + +#[test] +fn unlocked_manifest_inlines_real_avatar_bytes_downscaled() { + // 700px source (over MANIFEST_AVATAR_MAX_DIM) in a solid color. + let avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 700, + 700, + image::Rgba([9, 120, 33, 255]), + )); + let mut avatar_png = std::io::Cursor::new(Vec::new()); + avatar + .write_to(&mut avatar_png, image::ImageFormat::Png) + .unwrap(); + + let inlined = manifest_avatar_bytes(false, avatar_png.get_ref(), None) + .unwrap() + .expect("unlocked mints must inline the real avatar"); + let img = image::load_from_memory(&inlined).unwrap(); + assert_eq!( + (img.width(), img.height()), + (MANIFEST_AVATAR_MAX_DIM, MANIFEST_AVATAR_MAX_DIM) + ); + assert_eq!(img.to_rgba8().get_pixel(0, 0).0, [9, 120, 33, 255]); + + // Undecodable avatar bytes fail the mint (pre-spend), never silently + // produce a card whose import would wear the artwork as a face. + assert!(manifest_avatar_bytes(false, b"not a png", None).is_err()); +} + +#[test] +fn locked_manifest_keeps_data_url_only_avatar() { + // Locked mints must not inline fetched bytes (NIP-44 cap): only a record + // data URL carries over, exactly as before. + let unused = [0u8; 4]; + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("data:image/png;base64,aGk=")) + .unwrap() + .as_deref(), + Some(b"hi".as_slice()) + ); + assert_eq!( + manifest_avatar_bytes(true, &unused, Some("https://relay/media/a.png")).unwrap(), + None + ); + assert_eq!(manifest_avatar_bytes(true, &unused, None).unwrap(), None); +} + +#[test] +fn media_get_auth_gate_is_same_origin_only() { + // The minted Blossom get-auth header may only travel to the relay's own + // origin — scheme, host, and port all count (same contract as + // `validate_download_url` in `media_download.rs`). + let relay = "https://relay.example.com"; + assert!(is_same_origin( + "https://relay.example.com/media/abc.png", + relay + )); + // Different host, scheme, or port: no auth. + assert!(!is_same_origin( + "https://evil.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "http://relay.example.com/media/abc.png", + relay + )); + assert!(!is_same_origin( + "https://relay.example.com:8443/media/abc.png", + relay + )); + // Unparseable inputs fail closed. + assert!(!is_same_origin("not a url", relay)); + assert!(!is_same_origin( + "https://relay.example.com/x", + "also not a url" + )); + // Explicit port on both sides matches. + assert!(is_same_origin( + "http://localhost:3100/media/abc.png", + "http://localhost:3100" + )); +} + +#[test] +fn avatar_cap_rejects_before_appending_crossing_chunk() { + // The streaming accumulator must reject a chunk that would cross the + // cap BEFORE buffering it — this is what bounds memory when + // Content-Length is missing or dishonest. + let mut buf = vec![0u8; MAX_AVATAR_FETCH_BYTES - 1]; + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_ok()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + // Exactly at the cap: one more byte must fail and not grow the buffer. + assert!(append_within_avatar_cap(&mut buf, &[0u8]).is_err()); + assert_eq!(buf.len(), MAX_AVATAR_FETCH_BYTES); + + // A single oversized chunk is rejected outright. + let mut fresh = Vec::new(); + let oversized = vec![0u8; MAX_AVATAR_FETCH_BYTES + 1]; + assert!(append_within_avatar_cap(&mut fresh, &oversized).is_err()); + assert!(fresh.is_empty()); +} + +#[test] +fn save_rejects_plain_png_without_snapshot_chunk() { + // A plain PNG (no buzz_agent_snapshot chunk) must not be saveable as + // a card. Exercise the same validation the command runs. + let img = image::DynamicImage::new_rgba8(4, 4); + let mut png = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + assert!(decode_snapshot_png(&png).is_err()); +} + +#[test] +fn archived_sidecar_without_memory_level_defaults_to_none() { + // Every mint before the memory option existed embedded MemoryLevel::None + // structurally, so old sidecars (no memoryLevel field) must deserialize + // to None — the gallery's disclosure depends on this being honest. + let legacy = r#"{ + "storedFileName": "eva-1234.agent.png", + "fileName": "eva.agent.png", + "agentId": "abc", + "agentName": "Eva", + "designerNotes": "", + "locked": false, + "mintedAt": "2026-07-28T00:00:00Z" + }"#; + let meta: ArchivedCardMeta = serde_json::from_str(legacy).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::None); + + let with_level = legacy.replace( + "\"locked\": false,", + "\"locked\": false, \"memoryLevel\": \"everything\",", + ); + let meta: ArchivedCardMeta = serde_json::from_str(&with_level).unwrap(); + assert_eq!(meta.memory_level, MemoryLevel::Everything); +} + +#[test] +fn minted_card_serializes_memory_level_snake_case_value() { + // The TS layer narrows on the exact wire strings "none"/"core"/ + // "everything" — pin the serde representation the frontend will see. + let minted = MintedCard { + card_png_base64: String::new(), + file_name: "eva.agent.png".to_string(), + designer_notes: String::new(), + locked: false, + memory_level: MemoryLevel::Core, + }; + let json = serde_json::to_value(&minted).unwrap(); + assert_eq!(json["memoryLevel"], "core"); +} diff --git a/desktop/src-tauri/src/commands/personas/mod.rs b/desktop/src-tauri/src/commands/personas/mod.rs index 66f7296a25..0cd7ad0324 100644 --- a/desktop/src-tauri/src/commands/personas/mod.rs +++ b/desktop/src-tauri/src/commands/personas/mod.rs @@ -306,11 +306,14 @@ pub async fn set_persona_active( } pub(crate) const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4E, 0x47]; +mod card; mod snapshot; -pub use snapshot::encode_agent_snapshot_for_send; -pub use snapshot::export_agent_snapshot; +pub use card::*; +#[cfg(test)] +pub(crate) use snapshot::import::decode_snapshot_from_bytes; pub(crate) use snapshot::import::{ - decode_snapshot_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, + parse_snapshot_payload_from_bytes, resolve_snapshot_import_behavior, MAX_SNAPSHOT_JSON_BYTES, MAX_SNAPSHOT_PNG_BYTES, }; pub use snapshot::{confirm_agent_snapshot_import, preview_agent_snapshot_import}; +pub use snapshot::{encode_agent_snapshot_for_send, export_agent_snapshot}; diff --git a/desktop/src-tauri/src/commands/personas/snapshot.rs b/desktop/src-tauri/src/commands/personas/snapshot.rs index 583296dac0..e7bd1597e6 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot.rs @@ -142,7 +142,7 @@ pub(crate) fn validate_snapshot_encode_size(bytes_len: usize, is_png: bool) -> R } /// Parse a `memory_level` string to `MemoryLevel`. -fn parse_memory_level(s: &str) -> Result { +pub(crate) fn parse_memory_level(s: &str) -> Result { match s { "none" | "" => Ok(MemoryLevel::None), "core" => Ok(MemoryLevel::Core), @@ -153,6 +153,32 @@ fn parse_memory_level(s: &str) -> Result { } } +/// Flatten an owner-decrypted memory listing into manifest entries for +/// `memory_level`: `Core` takes the core entry only; `Everything` appends all +/// `mem/*` entries after it. Pure so both the export and card-mint paths share +/// (and tests can pin) the level → entries selection. +pub(crate) fn memory_entries_from_listing( + listing: crate::commands::engrams::AgentMemoryListing, + memory_level: MemoryLevel, +) -> Vec { + let mut entries = Vec::new(); + if let Some(core) = listing.core { + entries.push(AgentSnapshotMemoryEntry { + slug: core.slug, + body: core.body, + }); + } + if memory_level == MemoryLevel::Everything { + for mem in listing.memories { + entries.push(AgentSnapshotMemoryEntry { + slug: mem.slug, + body: mem.body, + }); + } + } + entries +} + /// Parse a `format` string to a PNG flag. fn parse_format_is_png(s: &str) -> Result { match s { @@ -267,22 +293,7 @@ pub(crate) async fn materialize_snapshot_bytes( // ── Fetch memory ───────────────────────────────────────────────────────── let memory_entries: Vec = if let Some(pubkey) = memory_pubkey { let listing = get_agent_memory(pubkey, app.clone(), state).await?; - let mut entries = Vec::new(); - if let Some(core) = listing.core { - entries.push(AgentSnapshotMemoryEntry { - slug: core.slug, - body: core.body, - }); - } - if memory_level == MemoryLevel::Everything { - for mem in listing.memories { - entries.push(AgentSnapshotMemoryEntry { - slug: mem.slug, - body: mem.body, - }); - } - } - entries + memory_entries_from_listing(listing, memory_level) } else { Vec::new() }; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs index 00a1457393..b769d74d7b 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/fidelity_tests.rs @@ -208,3 +208,64 @@ fn import_png_placeholder_keeps_manifest_avatar_fallback() { assert!(decoded.profile.avatar_data_url.is_none()); assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); } + +/// An unlocked trading card imports the agent's REAL avatar, never the card. +/// +/// Mint-shaped input: the PNG body is the generated card artwork, while the +/// manifest inlines the source avatar (`manifest_avatar_bytes` in `card.rs`). +/// The #3578 body-wins override must not fire when the manifest already +/// carries inline avatar bytes — otherwise the imported agent publishes the +/// 1500-wide card as its kind:0 picture. +#[test] +fn import_unlocked_card_uses_manifest_avatar_not_card_artwork() { + use crate::managed_agents::agent_snapshot::{decode_avatar_data_url, encode_snapshot_png}; + use base64::{engine::general_purpose::STANDARD, Engine as _}; + + // The real avatar: 4×3 solid blue, inlined in the manifest at mint time. + let real_avatar = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 4, + 3, + image::Rgba([23, 91, 177, 255]), + )); + let mut real_avatar_png = std::io::Cursor::new(Vec::new()); + real_avatar + .write_to(&mut real_avatar_png, image::ImageFormat::Png) + .unwrap(); + + let mut snapshot = make_snapshot(MemoryLevel::None, vec![]); + snapshot.profile.avatar_data_url = Some(format!( + "data:image/png;base64,{}", + STANDARD.encode(real_avatar_png.get_ref()) + )); + snapshot.profile.avatar_url = Some("https://relay.example/media/live-kind0.png".to_string()); + + // The card artwork: a distinct 1500×2250 solid red "trading card" as the + // PNG body — the exact dimensions the minter encodes for unlocked cards. + // Size matters: 2250px exceeds `snapshot_avatar`'s 2048px decode limit, + // so reaching the body override here wouldn't just import the wrong + // face — it would fail the import outright. + let card_art = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1500, + 2250, + image::Rgba([200, 16, 16, 255]), + )); + let mut card_png = std::io::Cursor::new(Vec::new()); + card_art + .write_to(&mut card_png, image::ImageFormat::Png) + .unwrap(); + let file_bytes = encode_snapshot_png(&snapshot, Some(card_png.get_ref())).unwrap(); + + // Production import decode: the effective avatar must be the real one. + let decoded = decode_snapshot_from_bytes(&file_bytes).unwrap(); + let avatar_bytes = + decode_avatar_data_url(decoded.profile.avatar_data_url.as_deref().unwrap()).unwrap(); + let imported = image::load_from_memory(&avatar_bytes).unwrap(); + assert_eq!( + (imported.width(), imported.height()), + (4, 3), + "imported avatar must be the source avatar, not the card artwork" + ); + assert_eq!(imported.to_rgba8().get_pixel(0, 0).0, [23, 91, 177, 255]); + // The live kind:0 URL fallback survives untouched. + assert_eq!(decoded.profile.avatar_url, snapshot.profile.avatar_url); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/import.rs b/desktop/src-tauri/src/commands/personas/snapshot/import.rs index eccf8ee601..d7f0323304 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/import.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/import.rs @@ -13,7 +13,11 @@ use tauri::{AppHandle, Emitter, State}; use crate::{ app_state::AppState, managed_agents::{ - agent_snapshot::{decode_snapshot_json, decode_snapshot_png, AgentSnapshot, MemoryLevel}, + agent_snapshot::{extract_chunk_payload_png, AgentSnapshot, MemoryLevel}, + agent_snapshot_envelope::{ + decrypt_envelope, parse_chunk_payload, resolve_unlock_secret, ChunkPayload, + LOCKED_CARD_REFUSAL, + }, load_managed_agents, load_personas, save_managed_agents, save_personas, AgentDefinition, ManagedAgentRecord, RespondTo, }, @@ -72,6 +76,16 @@ pub struct AgentSnapshotImportPreview { pub has_source_allowlist: bool, /// Number of source allowlist entries. pub source_allowlist_count: usize, + /// Full source allowlist entries, surfaced before import so hidden access + /// configuration is never reduced to a count. + pub source_allowlist: Vec, + /// Pretty-printed, validated manifest exactly as decoded from the file. + /// The UI makes this available before confirmation for full payload review. + pub manifest_json: String, + /// True when the snapshot came from a locked (encrypted) card that this + /// machine successfully unlocked. Cards that cannot be unlocked never + /// reach a preview — they fail closed with the locked-card refusal. + pub locked: bool, } /// The confirmation request sent from the UI after the user reviews the preview. @@ -210,50 +224,112 @@ const PNG_MAGIC: [u8; 4] = [0x89, 0x50, 0x4e, 0x47]; /// /// **Size cap:** PNG inputs over 10 MiB and JSON inputs over 5 MiB are rejected /// before allocation to avoid avoidable large-input work. -pub(crate) fn decode_snapshot_from_bytes( - file_bytes: &[u8], -) -> Result { - if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { +/// +/// **Locked cards:** a structurally valid locked envelope parses successfully +/// as `ChunkPayload::Locked` — no decryption happens here. Callers that can +/// unlock go through [`decode_snapshot_for_import`]; callers that only need +/// transit validation (e.g. `fetch_snapshot_bytes`) accept `Locked` as-is. +pub(crate) fn parse_snapshot_payload_from_bytes(file_bytes: &[u8]) -> Result { + let payload: ChunkPayload = if file_bytes.len() >= 4 && file_bytes[..4] == PNG_MAGIC { if file_bytes.len() > MAX_SNAPSHOT_PNG_BYTES { return Err(format!( "Snapshot file is too large ({} MiB). PNG snapshots must be under 10 MiB.", file_bytes.len() / (1024 * 1024) )); } - let mut snapshot = decode_snapshot_png(file_bytes)?; + let chunk_json = extract_chunk_payload_png(file_bytes)?; + let mut payload = parse_chunk_payload(&chunk_json)?; // The PNG image body is the portable avatar. It deliberately wins over - // manifest avatar fields, whose URL may only be reachable by the - // sender. A 1×1 export placeholder leaves the manifest fallback intact. - if let Some(avatar_data_url) = - crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url(file_bytes)? - { - snapshot.profile.avatar_data_url = Some(avatar_data_url); + // a manifest avatar *URL*, which may only be reachable by the sender. + // A 1×1 export placeholder leaves the manifest fallback intact. + // Inline manifest avatar *bytes* are authoritative and never + // overridden: trading cards supply the generated card artwork as the + // PNG body and carry the agent's real avatar inline — adopting the + // body there would import the card as the agent's face. + // Locked envelopes stay opaque here — there is no manifest to override + // until the unlock path decrypts one. + if let ChunkPayload::Plain(snapshot) = &mut payload { + if snapshot.profile.avatar_data_url.is_none() { + if let Some(avatar_data_url) = + crate::managed_agents::snapshot_avatar::snapshot_png_avatar_data_url( + file_bytes, + )? + { + snapshot.profile.avatar_data_url = Some(avatar_data_url); + } + } } - if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { - return Err( - "Snapshot is malformed: memory.level is 'none' but entries are present." - .to_string(), - ); + payload + } else { + // JSON path — apply size cap before serde allocation. + if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { + return Err(format!( + "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", + file_bytes.len() / (1024 * 1024) + )); } - return Ok(snapshot); - } - // JSON path — apply size cap before serde allocation. - if file_bytes.len() > MAX_SNAPSHOT_JSON_BYTES { - return Err(format!( - "Snapshot file is too large ({} MiB). JSON snapshots must be under 5 MiB.", - file_bytes.len() / (1024 * 1024) - )); - } - let snapshot = decode_snapshot_json(file_bytes)?; + parse_chunk_payload(file_bytes)? + }; // Consistency check: none + non-empty entries is always malformed, - // regardless of format. Mirrors the PNG path above so the rule is - // enforced at decode time for both formats. - if !snapshot.memory.entries.is_empty() && snapshot.memory.level == MemoryLevel::None { + // regardless of enclosing format. Enforced at decode time for plain + // payloads here, and after decryption for locked ones (see + // `enforce_memory_consistency` callers). + if let ChunkPayload::Plain(snapshot) = &payload { + enforce_memory_consistency(snapshot)?; + } + Ok(payload) +} + +/// The shared malformed-memory guard: `memory.level == none` with non-empty +/// entries is always rejected before any write. +fn enforce_memory_consistency( + snapshot: &crate::managed_agents::agent_snapshot::AgentSnapshot, +) -> Result<(), String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { return Err( "Snapshot is malformed: memory.level is 'none' but entries are present.".to_string(), ); } - Ok(snapshot) + Ok(()) +} + +/// Decode a plain snapshot from raw bytes, refusing locked cards. +/// +/// Test-only convenience: production call sites either unlock through +/// [`decode_snapshot_for_import`] or validate structurally through +/// [`parse_snapshot_payload_from_bytes`]. +#[cfg(test)] +pub(crate) fn decode_snapshot_from_bytes( + file_bytes: &[u8], +) -> Result { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok(*snapshot), + ChunkPayload::Locked(_) => Err(LOCKED_CARD_REFUSAL.to_string()), + } +} + +/// Decode a snapshot for import, unlocking locked cards when — and only +/// when — this machine holds one of the envelope's two exact key endpoints +/// (the owner identity or the named local agent record). +/// +/// Returns the decoded manifest and whether it came from a locked envelope. +/// When neither endpoint exists, fails closed with the locked-card refusal — +/// never partial plaintext, never crypto details. +pub(crate) fn decode_snapshot_for_import( + file_bytes: &[u8], + owner_keys: Option<&nostr::Keys>, + records: &[ManagedAgentRecord], +) -> Result<(crate::managed_agents::agent_snapshot::AgentSnapshot, bool), String> { + match parse_snapshot_payload_from_bytes(file_bytes)? { + ChunkPayload::Plain(snapshot) => Ok((*snapshot, false)), + ChunkPayload::Locked(envelope) => { + let secret = resolve_unlock_secret(&envelope, owner_keys, records) + .ok_or_else(|| LOCKED_CARD_REFUSAL.to_string())?; + let snapshot = decrypt_envelope(&envelope, &secret)?; + enforce_memory_consistency(&snapshot)?; + Ok((snapshot, true)) + } + } } async fn materialize_import_avatar( @@ -283,19 +359,38 @@ where /// `.agent.png` file. The format is sniffed from the content, not the /// extension, so an incorrectly-named file is handled correctly. /// +/// Locked cards are unlocked here when this machine holds one of the +/// envelope's two exact key endpoints; a card that cannot be unlocked fails +/// with the locked-card refusal (shown directly to the user), never a +/// partial preview. Identity-recovery mode is tolerated: owner keys are +/// simply unavailable, so only the agent-record endpoint can unlock. +/// /// Returns an `AgentSnapshotImportPreview` or a descriptive error. Errors -/// represent irrecoverable failures (corrupt / unsupported file) and are -/// shown directly to the user. +/// represent irrecoverable failures (corrupt / unsupported / locked-to- +/// someone-else file) and are shown directly to the user. #[tauri::command] pub async fn preview_agent_snapshot_import( file_bytes: Vec, file_name: String, + app: AppHandle, + state: State<'_, AppState>, ) -> Result { + // Key material + records are gathered up front (cheap, lock-scoped) so + // the blocking decode below owns plain data. + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; tokio::task::spawn_blocking(move || { reject_legacy_persona_filename(&file_name)?; - let snapshot = decode_snapshot_from_bytes(&file_bytes)?; + let (snapshot, locked) = + decode_snapshot_for_import(&file_bytes, owner_keys.as_ref(), &records)?; - Ok(build_agent_snapshot_import_preview(&snapshot)) + build_agent_snapshot_import_preview(&snapshot, locked) }) .await .map_err(|e| format!("spawn_blocking failed: {e}"))? @@ -303,7 +398,8 @@ pub async fn preview_agent_snapshot_import( pub(crate) fn build_agent_snapshot_import_preview( snapshot: &AgentSnapshot, -) -> AgentSnapshotImportPreview { + locked: bool, +) -> Result { let memory_level = match snapshot.memory.level { MemoryLevel::None => "none", MemoryLevel::Core => "core", @@ -311,7 +407,11 @@ pub(crate) fn build_agent_snapshot_import_preview( } .to_string(); - AgentSnapshotImportPreview { + let manifest_json = serde_json::to_string_pretty(snapshot) + .map_err(|e| format!("failed to render snapshot manifest: {e}"))?; + let source_allowlist = snapshot.definition.respond_to_allowlist.clone(); + + Ok(AgentSnapshotImportPreview { display_name: snapshot.profile.display_name.clone(), is_builtin: snapshot.definition.source_is_builtin, model: snapshot.definition.model.clone(), @@ -325,9 +425,12 @@ pub(crate) fn build_agent_snapshot_import_preview( .or_else(|| snapshot.profile.avatar_url.clone()), memory_level, memory_entry_count: snapshot.memory.entries.len(), - source_allowlist_count: snapshot.definition.respond_to_allowlist.len(), - has_source_allowlist: !snapshot.definition.respond_to_allowlist.is_empty(), - } + source_allowlist_count: source_allowlist.len(), + has_source_allowlist: !source_allowlist.is_empty(), + source_allowlist, + manifest_json, + locked, + }) } // ── `confirm_agent_snapshot_import` ────────────────────────────────────────── @@ -355,8 +458,20 @@ pub async fn confirm_agent_snapshot_import( app: AppHandle, state: State<'_, AppState>, ) -> Result { - // ── Phase 1: validate (no I/O) ─────────────────────────────────────────── - let snapshot = decode_snapshot_from_bytes(&input.file_bytes)?; + // ── Phase 1: validate (no writes) ──────────────────────────────────────── + // Locked cards unlock only via this machine's exact key endpoints; + // anything else fails closed here, before key generation. + let snapshot = { + let owner_keys = state.signing_keys().ok(); + let records = { + let _store_guard = state + .managed_agents_store_lock + .lock() + .map_err(|e| e.to_string())?; + load_managed_agents(&app)? + }; + decode_snapshot_for_import(&input.file_bytes, owner_keys.as_ref(), &records)?.0 + }; let display_name = snapshot.profile.display_name.trim().to_string(); if display_name.is_empty() { diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs index 4289310280..c453b09a9d 100644 --- a/desktop/src-tauri/src/commands/personas/snapshot/tests.rs +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests.rs @@ -564,7 +564,7 @@ fn import_preview_includes_exported_definition_metadata() { let bytes = crate::managed_agents::agent_snapshot::encode_snapshot_json(&snapshot).unwrap(); let decoded = decode_snapshot_from_bytes(&bytes).unwrap(); - let preview = build_agent_snapshot_import_preview(&decoded); + let preview = build_agent_snapshot_import_preview(&decoded, false).unwrap(); assert!(preview.is_builtin); assert_eq!(preview.model.as_deref(), Some("claude-opus-4-5")); @@ -949,51 +949,14 @@ fn test_parse_format_is_png_invalid_returns_error() { } // ── Export: validate_snapshot_encode_size ──────────────────────────────────── -// -// Tests call `validate_snapshot_encode_size` directly so they prove the exact -// production guard — not a manual reconstruction. Removing or reversing the -// check in production code will cause these tests to fail. -/// JSON: boundary-1 passes, boundary is the last legal byte count. -#[test] -fn validate_encode_size_json_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); -} +#[path = "tests_memory_entries.rs"] +mod memory_entries; -/// JSON: exactly at the boundary is the last accepted size. -#[test] -fn validate_encode_size_json_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); -} +#[path = "tests_encode_size.rs"] +mod encode_size; -/// JSON: boundary+1 is rejected. -#[test] -fn validate_encode_size_json_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +// ── Import: decode_snapshot_for_import (locked cards) ───────────────────── -/// PNG: boundary-1 passes. -#[test] -fn validate_encode_size_png_at_boundary_minus_1_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); -} - -/// PNG: exactly at the boundary passes. -#[test] -fn validate_encode_size_png_at_boundary_passes() { - assert!(super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); -} - -/// PNG: boundary+1 is rejected. -#[test] -fn validate_encode_size_png_over_boundary_is_rejected() { - let err = super::validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); - assert!( - err.contains("size limit"), - "error must mention size limit, got: {err}" - ); -} +#[path = "tests_locked.rs"] +mod locked_import; diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs new file mode 100644 index 0000000000..36eaa99716 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_encode_size.rs @@ -0,0 +1,55 @@ +//! Export-size guard tests for `validate_snapshot_encode_size`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test imports. +//! +//! Tests call `validate_snapshot_encode_size` directly so they prove the +//! exact production guard — not a manual reconstruction. Removing or +//! reversing the check in production code will cause these tests to fail. + +use super::*; + +/// JSON: boundary-1 passes, boundary is the last legal byte count. +#[test] +fn validate_encode_size_json_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES - 1, false).is_ok()); +} + +/// JSON: exactly at the boundary is the last accepted size. +#[test] +fn validate_encode_size_json_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES, false).is_ok()); +} + +/// JSON: boundary+1 is rejected. +#[test] +fn validate_encode_size_json_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_JSON_BYTES + 1, false).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} + +/// PNG: boundary-1 passes. +#[test] +fn validate_encode_size_png_at_boundary_minus_1_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES - 1, true).is_ok()); +} + +/// PNG: exactly at the boundary passes. +#[test] +fn validate_encode_size_png_at_boundary_passes() { + assert!(validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES, true).is_ok()); +} + +/// PNG: boundary+1 is rejected. +#[test] +fn validate_encode_size_png_over_boundary_is_rejected() { + let err = validate_snapshot_encode_size(MAX_SNAPSHOT_PNG_BYTES + 1, true).unwrap_err(); + assert!( + err.contains("size limit"), + "error must mention size limit, got: {err}" + ); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs new file mode 100644 index 0000000000..296444f78d --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_locked.rs @@ -0,0 +1,129 @@ +//! Locked-card import tests for `decode_snapshot_for_import`. +//! +//! Kept in a sibling file so `snapshot/tests.rs` stays under the +//! 1000-line gate; `#[path]`-included from there as a child module, +//! so `super::*` still resolves to the shared test helpers. + +use super::*; +use crate::commands::personas::snapshot::import::{ + decode_snapshot_for_import, parse_snapshot_payload_from_bytes, +}; +use crate::managed_agents::agent_snapshot_envelope::{ + encode_locked_snapshot_png, encrypt_snapshot_envelope, ChunkPayload, LOCKED_CARD_REFUSAL, +}; + +/// Build a keyed instance record holding real key material, so the +/// agent-endpoint unlock path resolves exactly as production does. +fn record_for(agent: &nostr::Keys) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: agent.public_key().to_hex(), + slug: None, + persona_id: Some("locked-test".to_string()), + private_key_nsec: nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(), + ..make_definition("") + } +} + +fn locked_png(owner: &nostr::Keys, agent: &nostr::Keys) -> (AgentSnapshot, Vec) { + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_locked_snapshot_png(&snapshot, owner, &agent.public_key(), None).unwrap(); + (snapshot, png) +} + +/// Owner identity key unlocks a locked card; `locked` is reported true. +#[test] +fn owner_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// A local managed-agent record holding the agent nsec unlocks the card +/// even when the owner identity does not match (e.g. re-import on the +/// agent's own machine under a different owner identity). +#[test] +fn agent_record_endpoint_unlocks_locked_png() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (snapshot, png) = locked_png(&owner, &agent); + let other_identity = nostr::Keys::generate(); + let records = vec![record_for(&agent)]; + let (decoded, locked) = + decode_snapshot_for_import(&png, Some(&other_identity), &records).unwrap(); + assert_eq!(decoded, snapshot); + assert!(locked); +} + +/// No matching endpoint → only the locked-card refusal, nothing else. +#[test] +fn stranger_fails_closed_with_refusal_only() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let stranger = nostr::Keys::generate(); + let unrelated_record = record_for(&nostr::Keys::generate()); + let err = decode_snapshot_for_import(&png, Some(&stranger), &[unrelated_record]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + // And with no key material at all. + let err = decode_snapshot_for_import(&png, None, &[]).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} + +/// Plain snapshots pass through unchanged with `locked == false`, with or +/// without key material in scope. +#[test] +fn plain_snapshot_passes_through_unlocked() { + use crate::managed_agents::agent_snapshot::encode_snapshot_png; + let snapshot = make_snapshot(MemoryLevel::None, vec![]); + let png = encode_snapshot_png(&snapshot, None).unwrap(); + let owner = nostr::Keys::generate(); + let (decoded, locked) = decode_snapshot_for_import(&png, Some(&owner), &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); + let (decoded, locked) = decode_snapshot_for_import(&png, None, &[]).unwrap(); + assert_eq!(decoded, snapshot); + assert!(!locked); +} + +/// The memory-consistency guard fires AFTER decryption too: a locked +/// envelope whose plaintext declares level none + non-empty entries is +/// rejected even for a legitimate endpoint. +#[test] +fn decrypted_manifest_memory_consistency_enforced() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let malformed = make_snapshot( + MemoryLevel::None, + vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked".to_string(), + }], + ); + // encrypt_snapshot_envelope does not guard memory consistency (the + // PNG encoder does), so this constructs the malicious payload. + let envelope = encrypt_snapshot_envelope(&malformed, &owner, &agent.public_key()).unwrap(); + let json = serde_json::to_vec(&envelope).unwrap(); + let err = decode_snapshot_for_import(&json, Some(&owner), &[]).unwrap_err(); + assert!( + err.contains("'none' but entries are present"), + "post-decrypt consistency guard must fire, got: {err}" + ); +} + +/// Transit validation (`fetch_snapshot_bytes` path) accepts a locked PNG +/// without any key material — structural validation only, no decryption. +#[test] +fn transit_validation_accepts_locked_png_without_keys() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let payload = parse_snapshot_payload_from_bytes(&png).unwrap(); + assert!(matches!(payload, ChunkPayload::Locked(_))); +} + +/// The keyless plain decoder refuses locked cards with the refusal. +#[test] +fn plain_decoder_refuses_locked_cards() { + let (owner, agent) = (nostr::Keys::generate(), nostr::Keys::generate()); + let (_snapshot, png) = locked_png(&owner, &agent); + let err = decode_snapshot_from_bytes(&png).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); +} diff --git a/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs new file mode 100644 index 0000000000..b17efa1ad1 --- /dev/null +++ b/desktop/src-tauri/src/commands/personas/snapshot/tests_memory_entries.rs @@ -0,0 +1,55 @@ +//! Tests for `memory_entries_from_listing` — the shared level → entries +//! selection used by both snapshot export and card minting. Split from +//! `tests.rs` to keep that file under the 1000-line gate; `#[path]`-included +//! from there as a child module, so `super::*` resolves to `tests`'s parent +//! scope re-exports. + +use super::*; + +fn listing_fixture() -> crate::commands::engrams::AgentMemoryListing { + let entry = |slug: &str, body: &str| crate::commands::engrams::EngramEntry { + slug: slug.to_string(), + body: body.to_string(), + event_id: "e".repeat(64), + created_at: 1, + outgoing_refs: vec![], + }; + crate::commands::engrams::AgentMemoryListing { + core: Some(entry("core", "core body")), + memories: vec![entry("mem/a", "a body"), entry("mem/b", "b body")], + truncated: false, + fetched_at: 1, + } +} + +#[test] +fn memory_entries_core_takes_core_only() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Core); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].slug, "core"); + assert_eq!(entries[0].body, "core body"); +} + +#[test] +fn memory_entries_everything_appends_mem_entries_after_core() { + let entries = memory_entries_from_listing(listing_fixture(), MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["core", "mem/a", "mem/b"] + ); +} + +#[test] +fn memory_entries_missing_core_still_yields_mem_entries_for_everything() { + let mut listing = listing_fixture(); + listing.core = None; + let entries = memory_entries_from_listing(listing, MemoryLevel::Everything); + assert_eq!( + entries.iter().map(|e| e.slug.as_str()).collect::>(), + vec!["mem/a", "mem/b"] + ); + + let mut core_only = listing_fixture(); + core_only.core = None; + assert!(memory_entries_from_listing(core_only, MemoryLevel::Core).is_empty()); +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6814008f0d..c4b733e3e0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -65,10 +65,7 @@ use mesh_llm_stubs::*; #[cfg(all(feature = "mesh-llm", target_os = "macos"))] use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown}; use shutdown::{is_restart_request, shut_down_app}; -use std::sync::{ - atomic::{AtomicBool, Ordering}, - Arc, -}; +use std::sync::{atomic::AtomicBool, atomic::Ordering, Arc}; use tauri::{Emitter, Manager, RunEvent}; #[cfg(target_os = "macos")] use tauri::{Listener, WindowEvent}; @@ -851,6 +848,12 @@ pub fn run() { update_team, delete_team, export_agent_snapshot, + card_mint_key_status, + card_mint_save_openai_key, + mint_agent_card, + save_agent_card, + list_agent_cards, + load_agent_card, preview_agent_snapshot_import, confirm_agent_snapshot_import, encode_agent_snapshot_for_send, diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs index 16a0d35b23..7c08e7095f 100644 --- a/desktop/src-tauri/src/managed_agents/agent_snapshot.rs +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot.rs @@ -306,9 +306,22 @@ pub fn encode_snapshot_png( ); } - // Manifest → JSON → base64 for the tEXt chunk payload. + // Manifest → JSON for the tEXt chunk payload. The payload/PNG composition + // is shared with the locked-card encoder in `agent_snapshot_envelope`; + // plain cards remain byte-identical to the pre-envelope encoder. let json_bytes = encode_snapshot_json(snapshot)?; - let chunk_text = STANDARD.encode(&json_bytes); + encode_chunk_payload_png(&json_bytes, avatar_bytes) +} + +/// Encode arbitrary chunk-payload JSON (plain manifest or locked envelope) +/// into a PNG carrying it base64-encoded in the `buzz_agent_snapshot` tEXt +/// chunk. Shared by the plain encoder above and +/// `agent_snapshot_envelope::encode_locked_snapshot_png`. +pub(crate) fn encode_chunk_payload_png( + json_bytes: &[u8], + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + let chunk_text = STANDARD.encode(json_bytes); // Use the avatar as the PNG image body, transcoding decodable non-PNG // avatars. Fall back to a minimal 1×1 transparent placeholder only when @@ -334,8 +347,11 @@ pub fn encode_snapshot_png( Ok(png_bytes) } -/// Decode a manifest from a `.agent.png` tEXt chunk. -pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { +/// Extract and base64-decode the raw `buzz_agent_snapshot` chunk payload +/// (JSON bytes) from a PNG, without interpreting it. The payload may be a +/// plain manifest or a locked envelope — callers dispatch on the parsed +/// `format` via `agent_snapshot_envelope::parse_chunk_payload`. +pub(crate) fn extract_chunk_payload_png(png_bytes: &[u8]) -> Result, String> { let decoder = Decoder::new(Cursor::new(png_bytes)); let reader = decoder .read_info() @@ -349,10 +365,18 @@ pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { .map(|c| c.text.as_str()) .ok_or_else(|| "PNG does not contain a buzz_agent_snapshot tEXt chunk".to_string())?; - let json_bytes = STANDARD + STANDARD .decode(chunk_text.trim()) - .map_err(|e| format!("Invalid base64 in PNG chunk: {e}"))?; + .map_err(|e| format!("Invalid base64 in PNG chunk: {e}")) +} +/// Decode a manifest from a `.agent.png` tEXt chunk. +/// +/// Plain snapshots only — a locked (encrypted) chunk payload fails here with +/// the manifest format error. Import paths that must handle locked cards go +/// through `agent_snapshot_envelope::parse_chunk_payload` instead. +pub fn decode_snapshot_png(png_bytes: &[u8]) -> Result { + let json_bytes = extract_chunk_payload_png(png_bytes)?; decode_snapshot_json(&json_bytes) } @@ -473,527 +497,5 @@ fn inject_text_chunk(png_bytes: &[u8], keyword: &str, text: &str) -> Result ManagedAgentRecord { - ManagedAgentRecord { - pubkey: "deadbeef".to_string(), - name: "Test Agent".to_string(), - display_name: Some("Test Agent Display".to_string()), - persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot - team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot - private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot - auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot - relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot - avatar_url: Some("https://example.com/avatar.png".to_string()), - acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot - agent_command: "goose".to_string(), // MUST NOT appear in snapshot - agent_command_override: Some("goose-override".to_string()), // MUST NOT appear - agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot - mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot - turn_timeout_seconds: 120, // deprecated, MUST NOT appear - idle_timeout_seconds: Some(30), - max_turn_duration_seconds: Some(600), - parallelism: 2, - system_prompt: Some("You are a test agent.".to_string()), - model: Some("claude-opus-4".to_string()), - provider: Some("anthropic".to_string()), - persona_source_version: Some("v1.0".to_string()), // MUST NOT appear - env_vars: { - let mut m = BTreeMap::new(); - m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear - m - }, - start_on_app_launch: true, - auto_restart_on_config_change: true, - runtime_pid: Some(12345), // MUST NOT appear - backend: BackendKind::Provider { - // MUST NOT appear — carries a provider secret - id: "SENTINEL_BACKEND_ID".to_string(), - config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), - }, - backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear - provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear - persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear - persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-02T00:00:00Z".to_string(), - last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear - last_stopped_at: None, - last_exit_code: Some(0), // MUST NOT appear - last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear - last_error_code: Some(42), // MUST NOT appear - respond_to: RespondTo::default(), - respond_to_allowlist: vec!["pubkey1hex".to_string()], - slug: Some("test-agent".to_string()), - runtime: Some("goose".to_string()), - name_pool: vec!["Alice".to_string(), "Bob".to_string()], - is_builtin: false, - is_active: true, - shared: false, - source_team: Some("team-id-123".to_string()), // MUST NOT appear - source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear - definition_respond_to: Some("allowlist".to_string()), - catalog_source: None, - definition_respond_to_allowlist: vec!["abc123def".to_string()], - definition_parallelism: Some(4), - relay_mesh: None, - } - } - - // ── Round-trip tests ────────────────────────────────────────────────────── - - #[test] - fn json_round_trip_config_only() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn json_round_trip_with_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "I am a test agent.".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/research".to_string(), - body: "Some research notes.".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - let parsed = decode_snapshot_json(&bytes).unwrap(); - assert_eq!(parsed, snapshot); - } - - #[test] - fn png_round_trip_no_memory() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); - assert_eq!(parsed.memory.level, MemoryLevel::None); - } - - #[test] - fn png_round_trip_with_avatar_png() { - // Build a minimal PNG avatar. - let avatar = make_png_with_text("dummy", "value").unwrap(); - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); - // Avatar should be inlined as a data URL. - assert!(snapshot - .profile - .avatar_data_url - .as_deref() - .unwrap_or("") - .starts_with("data:image/png;base64,")); - - let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - assert_eq!(parsed.definition.name, snapshot.definition.name); - } - - #[test] - fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { - let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( - 3, - 2, - image::Rgb([0x12, 0x34, 0x56]), - )); - let mut jpeg_bytes = Vec::new(); - avatar - .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) - .unwrap(); - - let snapshot = build_snapshot( - &minimal_record(), - MemoryLevel::None, - vec![], - Some(&jpeg_bytes), - ); - let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); - let decoder = Decoder::new(Cursor::new(png_bytes)); - let reader = decoder.read_info().unwrap(); - - assert_eq!((reader.info().width, reader.info().height), (3, 2)); - } - - // ── PNG memory parity ───────────────────────────────────────────────────── - - #[test] - fn png_round_trip_with_core_memory() { - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }]; - let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_round_trip_with_everything_memory() { - let record = minimal_record(); - let entries = vec![ - AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "remember this".to_string(), - }, - AgentSnapshotMemoryEntry { - slug: "mem/notes".to_string(), - body: "private notes".to_string(), - }, - ]; - let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); - - let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); - let parsed = decode_snapshot_png(&png_bytes).unwrap(); - - assert_eq!(parsed.memory, snapshot.memory); - } - - #[test] - fn png_export_with_no_memory_succeeds() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert!(encode_snapshot_png(&snapshot, None).is_ok()); - } - - #[test] - fn png_export_rejects_none_level_with_nonempty_entries() { - // Inconsistent state: level == None but entries is non-empty. - // The encoder must reject this to prevent a memory-leak bypass. - let record = minimal_record(); - let entries = vec![AgentSnapshotMemoryEntry { - slug: "core".to_string(), - body: "leaked memory".to_string(), - }]; - // Build with entries, then override level to None in the struct. - let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); - snapshot.memory.level = MemoryLevel::None; // force inconsistency - let result = encode_snapshot_png(&snapshot, None); - assert!( - result.is_err(), - "PNG encoder must reject level=None with non-empty entries" - ); - assert!( - result - .unwrap_err() - .contains("memory.level 'none' and non-empty memory entries"), - "Error must explain the malformed memory state" - ); - } - - // ── Secret exclusion tests ──────────────────────────────────────────────── - // - // These tests assert that every field in the exclusion list is absent from - // the serialized snapshot. We serialize to JSON and assert the key is NOT - // present. - - fn snapshot_json_string(record: &ManagedAgentRecord) -> String { - let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); - let bytes = encode_snapshot_json(&snapshot).unwrap(); - String::from_utf8(bytes).unwrap() - } - - #[test] - fn secret_exclusion_private_key_nsec_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("nsec1secret"), - "nsec must not appear in snapshot" - ); - assert!( - !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), - "privateKeyNsec field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_auth_tag_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("auth-tag-secret"), - "auth_tag value must not appear in snapshot" - ); - assert!( - !json.contains("authTag") && !json.contains("auth_tag"), - "authTag field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_env_vars_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("API_KEY") && !json.contains("secret123"), - "env_vars content must not appear in snapshot" - ); - assert!( - !json.contains("envVars") && !json.contains("env_vars"), - "envVars field must not appear in snapshot" - ); - } - - #[test] - fn secret_exclusion_relay_url_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("wss://relay.example.com"), - "relay_url value must not appear in snapshot" - ); - assert!( - !json.contains("relayUrl") && !json.contains("relay_url"), - "relayUrl field must not appear in snapshot" - ); - } - - #[test] - fn snapshot_omits_removed_mcp_toolsets_config() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), - "removed MCP toolsets config must not re-enter snapshots" - ); - } - - #[test] - fn secret_exclusion_machine_commands_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - // acp_command / agent_command / agent_command_override / agent_args / mcp_command - assert!( - !json.contains("/usr/local/bin/acp"), - "acp_command path must not appear" - ); - assert!( - !json.contains("acpCommand") && !json.contains("acp_command"), - "acpCommand field must not appear" - ); - assert!( - !json.contains("agentCommand") && !json.contains("agent_command"), - "agentCommand field must not appear" - ); - assert!( - !json.contains("mcpCommand") && !json.contains("mcp_command"), - "mcpCommand field must not appear" - ); - } - - #[test] - fn secret_exclusion_runtime_state_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("runtimePid") && !json.contains("runtime_pid"), - "runtimePid must not appear" - ); - assert!( - !json.contains("backendAgentId") && !json.contains("backend_agent_id"), - "backendAgentId must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_AGENT_ID"), - "backendAgentId value must not appear" - ); - assert!( - !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), - "providerBinaryPath must not appear" - ); - assert!( - !json.contains("SENTINEL_PROVIDER_BINARY"), - "providerBinaryPath value must not appear" - ); - assert!( - !json.contains("lastStartedAt") && !json.contains("last_started_at"), - "lastStartedAt must not appear" - ); - assert!( - !json.contains("lastExitCode") && !json.contains("last_exit_code"), - "lastExitCode must not appear" - ); - // backend blob — neither the type tag nor provider secret must leak. - assert!( - !json.contains("\"backend\"") && !json.contains("backend"), - "backend field must not appear" - ); - assert!( - !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), - "backend config values must not appear" - ); - // last_error / last_error_code - assert!( - !json.contains("lastError") && !json.contains("last_error"), - "lastError must not appear" - ); - assert!( - !json.contains("SENTINEL_LAST_ERROR"), - "lastError value must not appear" - ); - assert!( - !json.contains("lastErrorCode") && !json.contains("last_error_code"), - "lastErrorCode must not appear" - ); - } - - #[test] - fn secret_exclusion_lineage_ids_absent() { - let record = minimal_record(); - let json = snapshot_json_string(&record); - assert!( - !json.contains("team-id-123"), - "source_team value must not appear" - ); - assert!( - !json.contains("sourceTeam") && !json.contains("source_team"), - "sourceTeam field must not appear" - ); - assert!( - !json.contains("sourceTeamPersonaSlug"), - "sourceTeamPersonaSlug must not appear" - ); - assert!( - !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), - "personaSourceVersion must not appear" - ); - // personaId - assert!( - !json.contains("personaId") && !json.contains("persona_id"), - "personaId field must not appear" - ); - assert!( - !json.contains("SENTINEL_PERSONA_ID"), - "personaId value must not appear" - ); - // teamId - assert!( - !json.contains("teamId") && !json.contains("team_id"), - "teamId field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_ID"), - "teamId value must not appear" - ); - // personaTeamDir - assert!( - !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), - "personaTeamDir field must not appear" - ); - assert!( - !json.contains("SENTINEL_TEAM_DIR"), - "personaTeamDir value must not appear" - ); - // personaNameInTeam - assert!( - !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), - "personaNameInTeam field must not appear" - ); - assert!( - !json.contains("SENTINEL_NAME_IN_TEAM"), - "personaNameInTeam value must not appear" - ); - } - - // ── Definition field presence tests ────────────────────────────────────── - - #[test] - fn definition_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - - assert_eq!(snapshot.definition.name, "Test Agent Display"); - assert!(!snapshot.definition.source_is_builtin); - assert_eq!( - snapshot.definition.system_prompt.as_deref(), - Some("You are a test agent.") - ); - assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); - assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); - assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); - assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); - // definition_respond_to maps to respond_to in the snapshot definition - assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); - // definition_respond_to_allowlist should be included - assert!(!snapshot.definition.respond_to_allowlist.is_empty()); - } - - #[test] - fn profile_fields_present_in_snapshot() { - let record = minimal_record(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); - assert_eq!(snapshot.profile.display_name, "Test Agent Display"); - // No bytes → should fall back to avatar_url - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/avatar.png") - ); - assert!(snapshot.profile.avatar_data_url.is_none()); - } - - #[test] - fn avatar_inlined_when_under_size_limit() { - let record = minimal_record(); - let small_png = make_png_with_text("k", "v").unwrap(); - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); - assert!(snapshot.profile.avatar_data_url.is_some()); - assert!(snapshot.profile.avatar_url.is_none()); - } - - #[test] - fn avatar_url_fallback_when_over_size_limit() { - let mut record = minimal_record(); - record.avatar_url = Some("https://example.com/big.png".to_string()); - // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. - let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; - let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); - assert!(snapshot.profile.avatar_data_url.is_none()); - assert_eq!( - snapshot.profile.avatar_url.as_deref(), - Some("https://example.com/big.png") - ); - } - - // ── Format/version validation ───────────────────────────────────────────── - - #[test] - fn invalid_format_discriminator_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.format = "not-a-buzz-snapshot".to_string(); - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot format")); - } - - #[test] - fn unsupported_version_is_rejected() { - let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); - snapshot.version = 99; - let bytes = serde_json::to_vec(&snapshot).unwrap(); - let result = decode_snapshot_json(&bytes); - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Unsupported snapshot version")); - } -} +#[path = "agent_snapshot_tests.rs"] +mod tests; diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs new file mode 100644 index 0000000000..8508c27073 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_envelope.rs @@ -0,0 +1,638 @@ +//! Locked (encrypted) agent-card envelope — NIP-44 v2 over the snapshot manifest. +//! +//! A locked card carries the same `buzz_agent_snapshot` tEXt chunk as a plain +//! card, but the chunk JSON is a typed outer envelope whose ciphertext +//! decrypts to the ordinary manifest. The NIP-44 v2 conversation key is +//! symmetric over the (owner, agent) pair, so BOTH the owner's and the +//! agent's nsec decrypt the card — nobody else's does (NIP-AE's scheme). +//! +//! Wire contract (agreed with Wren, buzz-agent-trading-cards thread): +//! - Plain cards keep today's exact bytes; detection dispatches once on the +//! exact `format` discriminator and rejects unknown versions/schemes +//! rather than falling through to manifest parsing. +//! - Key lookup is exact-endpoint only: the owner identity key when its +//! pubkey equals `ownerPubkey`, or a hydrated local managed-agent record +//! whose record pubkey AND derived-secret pubkey equal `agentPubkey`. +//! No trial decryption; anything else fails closed as locked. +//! - Caps beyond the outer 10 MiB PNG gate: 65,535-byte NIP-44 plaintext +//! limit on the serialized manifest BEFORE encryption; envelope JSON and +//! ciphertext are capped before serde/base64/decrypt work; decrypted bytes +//! are capped before snapshot parsing. +//! - Decrypt/auth failures return only the locked-card refusal — never +//! partial plaintext or crypto details. + +use buzz_core_pkg::engram::NIP44_PLAINTEXT_MAX; +use nostr::nips::nip44::{self, Version}; +use nostr::{Keys, PublicKey, SecretKey}; +use serde::{Deserialize, Serialize}; + +use super::agent_snapshot::{ + decode_snapshot_json, encode_chunk_payload_png, encode_snapshot_json, AgentSnapshot, + MemoryLevel, FORMAT_DISCRIMINATOR, +}; +use super::types::ManagedAgentRecord; + +/// Discriminator for the locked envelope. Distinct from the plain manifest's +/// `buzz-agent-snapshot` so detection never guesses. +pub const LOCKED_FORMAT: &str = "buzz-agent-snapshot-encrypted"; +/// Envelope schema version this module produces and accepts. +pub const LOCKED_VERSION: u32 = 1; +/// Encryption scheme identifier this module produces and accepts. +pub const LOCKED_SCHEME: &str = "nip44-v2"; + +/// A max-size NIP-44 v2 payload (1 version + 32 nonce + 2 len + 65,536 +/// padded + 32 MAC = 65,603 bytes) base64-encodes to 87,472 chars. +/// Anything larger is rejected before base64/decrypt work. +pub const MAX_LOCKED_CIPHERTEXT_BYTES: usize = 90_000; +/// Envelope JSON = ciphertext + two pubkeys + fixed keys. Rejected before +/// typed deserialization. +pub const MAX_LOCKED_ENVELOPE_JSON_BYTES: usize = MAX_LOCKED_CIPHERTEXT_BYTES + 1024; + +/// The only error a failed unlock may surface. Deliberately says nothing +/// about which key was tried or why decryption failed. +pub const LOCKED_CARD_REFUSAL: &str = + "This card is locked to its owner and agent. Only they can import it."; + +// ── Envelope types ──────────────────────────────────────────────────────────── + +/// Typed outer envelope stored (base64 JSON) in the `buzz_agent_snapshot` +/// chunk of a locked card. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedSnapshotEnvelope { + /// Always [`LOCKED_FORMAT`]. + pub format: String, + /// Always [`LOCKED_VERSION`]. + pub version: u32, + pub encryption: LockedEncryption, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct LockedEncryption { + /// Always [`LOCKED_SCHEME`]. + pub scheme: String, + /// Owner identity pubkey (64 lowercase hex). Plaintext so a decryptor + /// knows which counterparty to pair with. + pub owner_pubkey: String, + /// Agent instance pubkey (64 lowercase hex). + pub agent_pubkey: String, + /// NIP-44 v2 ciphertext (base64) of the plain manifest JSON. + pub ciphertext: String, +} + +/// Result of parsing a chunk payload: either today's plain manifest or a +/// validated locked envelope. The plain manifest is boxed because it may +/// inline a multi-KB avatar data URL, dwarfing the envelope variant. +#[derive(Debug)] +pub enum ChunkPayload { + Plain(Box), + Locked(LockedSnapshotEnvelope), +} + +/// Minimal probe used to read the `format` discriminator without building a +/// full JSON tree for large plain manifests. +#[derive(Deserialize)] +struct FormatProbe { + #[serde(default)] + format: Option, +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +/// Canonical pubkey check: exactly 64 lowercase hex chars that parse as a +/// valid x-only pubkey. Lowercase is required so string comparisons against +/// record pubkeys (always `to_hex()` output) stay sound. Curve validation is +/// explicit: nostr's `PublicKey::from_hex` only decodes 32 bytes and defers +/// lift-x validation to `xonly()`, so a non-point like `"f" * 64` would +/// otherwise pass structurally and fail only at decrypt time. +pub(crate) fn parse_canonical_pubkey(field: &str, value: &str) -> Result { + if value.len() != 64 + || !value + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) + { + return Err(format!( + "Locked card envelope has a malformed {field} (expected 64 lowercase hex chars)." + )); + } + let pubkey = PublicKey::from_hex(value) + .map_err(|_| format!("Locked card envelope has an invalid {field}."))?; + pubkey + .xonly() + .map_err(|_| format!("Locked card envelope has an invalid {field} (not a curve point)."))?; + Ok(pubkey) +} + +/// Structural validation of a locked envelope: exact version + scheme, +/// canonical pubkeys, distinct endpoints, bounded ciphertext. Does no +/// key lookup or crypto. +pub fn validate_envelope( + envelope: &LockedSnapshotEnvelope, +) -> Result<(PublicKey, PublicKey), String> { + if envelope.format != LOCKED_FORMAT { + return Err(format!( + "Unsupported locked card format: {:?} (expected {LOCKED_FORMAT:?})", + envelope.format + )); + } + if envelope.version != LOCKED_VERSION { + return Err(format!( + "Unsupported locked card envelope version: {} (expected {LOCKED_VERSION})", + envelope.version + )); + } + if envelope.encryption.scheme != LOCKED_SCHEME { + return Err(format!( + "Unsupported locked card encryption scheme: {:?} (expected {LOCKED_SCHEME:?})", + envelope.encryption.scheme + )); + } + let owner = parse_canonical_pubkey("ownerPubkey", &envelope.encryption.owner_pubkey)?; + let agent = parse_canonical_pubkey("agentPubkey", &envelope.encryption.agent_pubkey)?; + if owner == agent { + return Err("Locked card envelope owner and agent pubkeys must differ.".to_string()); + } + if envelope.encryption.ciphertext.len() > MAX_LOCKED_CIPHERTEXT_BYTES { + return Err("Locked card ciphertext exceeds the maximum size.".to_string()); + } + if envelope.encryption.ciphertext.is_empty() { + return Err("Locked card ciphertext is empty.".to_string()); + } + Ok((owner, agent)) +} + +// ── Dispatch ────────────────────────────────────────────────────────────────── + +/// Parse a raw chunk payload (JSON bytes from `extract_chunk_payload_png` or +/// an `.agent.json` file) and dispatch on the exact `format` discriminator. +/// +/// - `buzz-agent-snapshot` → full plain-manifest decode + validation. +/// - `buzz-agent-snapshot-encrypted` → size caps, typed envelope parse, +/// structural validation. No decryption happens here. +/// - anything else (including missing `format`) → error, never a fall-through. +pub fn parse_chunk_payload(json_bytes: &[u8]) -> Result { + let probe: FormatProbe = + serde_json::from_slice(json_bytes).map_err(|e| format!("Invalid snapshot JSON: {e}"))?; + match probe.format.as_deref() { + Some(f) if f == FORMAT_DISCRIMINATOR => Ok(ChunkPayload::Plain(Box::new( + decode_snapshot_json(json_bytes)?, + ))), + Some(f) if f == LOCKED_FORMAT => { + // Cap the envelope JSON before typed deserialization; a locked + // envelope is small by construction (unlike plain manifests, + // which may inline a multi-MB avatar). + if json_bytes.len() > MAX_LOCKED_ENVELOPE_JSON_BYTES { + return Err("Locked card envelope exceeds the maximum size.".to_string()); + } + let envelope: LockedSnapshotEnvelope = serde_json::from_slice(json_bytes) + .map_err(|e| format!("Invalid locked card envelope: {e}"))?; + validate_envelope(&envelope)?; + Ok(ChunkPayload::Locked(envelope)) + } + Some(other) => Err(format!("Unsupported snapshot format: {other:?}")), + None => Err("Snapshot payload has no format discriminator.".to_string()), + } +} + +// ── Encrypt ─────────────────────────────────────────────────────────────────── + +/// Encrypt a snapshot manifest into a locked envelope under the NIP-44 v2 +/// conversation key for (owner secret, agent pubkey). +/// +/// Fails clearly (never silently truncates) when the serialized manifest +/// exceeds the NIP-44 plaintext limit. +pub fn encrypt_snapshot_envelope( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, +) -> Result { + let json_bytes = encode_snapshot_json(snapshot)?; + if json_bytes.len() > NIP44_PLAINTEXT_MAX { + return Err(format!( + "Agent manifest is too large to lock ({} bytes; the encrypted \ + format caps at {NIP44_PLAINTEXT_MAX}). Reduce the avatar size \ + or mint an unlocked card.", + json_bytes.len() + )); + } + let plaintext = std::str::from_utf8(&json_bytes) + .map_err(|e| format!("Manifest JSON was not UTF-8: {e}"))?; + let ciphertext = nip44::encrypt( + owner_keys.secret_key(), + agent_pubkey, + plaintext, + Version::V2, + ) + .map_err(|e| format!("Failed to encrypt card manifest: {e}"))?; + + Ok(LockedSnapshotEnvelope { + format: LOCKED_FORMAT.to_string(), + version: LOCKED_VERSION, + encryption: LockedEncryption { + scheme: LOCKED_SCHEME.to_string(), + owner_pubkey: owner_keys.public_key().to_hex(), + agent_pubkey: agent_pubkey.to_hex(), + ciphertext, + }, + }) +} + +/// Encode a snapshot into a LOCKED `.agent.png`: encrypt the manifest into +/// the envelope, then compose the PNG through the same chunk encoder plain +/// cards use. Mirrors `encode_snapshot_png`'s structural memory guard. +pub fn encode_locked_snapshot_png( + snapshot: &AgentSnapshot, + owner_keys: &Keys, + agent_pubkey: &PublicKey, + avatar_bytes: Option<&[u8]>, +) -> Result, String> { + if snapshot.memory.level == MemoryLevel::None && !snapshot.memory.entries.is_empty() { + return Err( + "Cannot write a snapshot with memory.level 'none' and non-empty memory entries." + .to_string(), + ); + } + let envelope = encrypt_snapshot_envelope(snapshot, owner_keys, agent_pubkey)?; + let envelope_json = serde_json::to_vec(&envelope) + .map_err(|e| format!("Failed to serialize locked card envelope: {e}"))?; + encode_chunk_payload_png(&envelope_json, avatar_bytes) +} + +// ── Decrypt ─────────────────────────────────────────────────────────────────── + +/// Exact-endpoint key resolution (no trial decryption): +/// - the owner identity secret, only when its pubkey equals `ownerPubkey`; +/// - a hydrated local managed-agent record whose record pubkey AND +/// derived-secret pubkey both equal `agentPubkey`. +/// +/// Returns `None` when neither exact endpoint exists — callers fail closed +/// with [`LOCKED_CARD_REFUSAL`]. +pub fn resolve_unlock_secret( + envelope: &LockedSnapshotEnvelope, + owner_keys: Option<&Keys>, + records: &[ManagedAgentRecord], +) -> Option { + if let Some(keys) = owner_keys { + if keys.public_key().to_hex() == envelope.encryption.owner_pubkey { + return Some(keys.secret_key().clone()); + } + } + let record = records + .iter() + .find(|r| r.pubkey == envelope.encryption.agent_pubkey)?; + let agent_keys = Keys::parse(record.private_key_nsec.trim()).ok()?; + if agent_keys.public_key().to_hex() != envelope.encryption.agent_pubkey { + return None; + } + Some(agent_keys.secret_key().clone()) +} + +/// Decrypt a validated envelope with `my_secret`, which must be one of the +/// envelope's two exact endpoints (its derived pubkey selects the +/// counterparty). Returns the decoded, validated snapshot manifest. +/// +/// Every auth/crypto failure maps to [`LOCKED_CARD_REFUSAL`] — nothing about +/// the failure mode leaks. Manifest decode errors after a successful decrypt +/// are surfaced normally (the caller proved key possession). +pub fn decrypt_envelope( + envelope: &LockedSnapshotEnvelope, + my_secret: &SecretKey, +) -> Result { + let (owner_pub, agent_pub) = validate_envelope(envelope)?; + let my_pub = Keys::new(my_secret.clone()).public_key(); + let counterparty = if my_pub == owner_pub { + agent_pub + } else if my_pub == agent_pub { + owner_pub + } else { + return Err(LOCKED_CARD_REFUSAL.to_string()); + }; + + let plaintext = nip44::decrypt(my_secret, &counterparty, &envelope.encryption.ciphertext) + .map_err(|_| LOCKED_CARD_REFUSAL.to_string())?; + if plaintext.len() > NIP44_PLAINTEXT_MAX { + return Err(LOCKED_CARD_REFUSAL.to_string()); + } + decode_snapshot_json(plaintext.as_bytes()) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::managed_agents::agent_snapshot::{ + extract_chunk_payload_png, AgentSnapshotDefinition, AgentSnapshotMemory, + AgentSnapshotProfile, FORMAT_VERSION, + }; + + fn sample_snapshot() -> AgentSnapshot { + AgentSnapshot { + format: FORMAT_DISCRIMINATOR.to_string(), + version: FORMAT_VERSION, + definition: AgentSnapshotDefinition { + name: "Locked Test".to_string(), + system_prompt: Some("You are a locked test agent.".to_string()), + runtime: None, + model: None, + provider: None, + parallelism: Some(1), + respond_to: None, + respond_to_allowlist: Vec::new(), + name_pool: Vec::new(), + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + source_is_builtin: false, + }, + profile: AgentSnapshotProfile { + display_name: "Locked Test".to_string(), + about: None, + avatar_data_url: None, + avatar_url: None, + }, + memory: AgentSnapshotMemory { + level: MemoryLevel::None, + entries: Vec::new(), + }, + } + } + + fn owner_agent_keys() -> (Keys, Keys) { + (Keys::generate(), Keys::generate()) + } + + /// Minimal hydrated record for endpoint-resolution tests. Only the + /// pubkey/nsec pair matters here. + fn record_with_keys(pubkey: String, private_key_nsec: String) -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey, + name: "Locked Test".to_string(), + persona_id: None, + private_key_nsec, + auth_tag: None, + relay_url: "ws://localhost:3000".to_string(), + avatar_url: None, + acp_command: "buzz-acp".to_string(), + agent_command: "goose".to_string(), + agent_args: vec![], + mcp_command: String::new(), + turn_timeout_seconds: 300, + idle_timeout_seconds: None, + max_turn_duration_seconds: None, + parallelism: 1, + system_prompt: None, + model: None, + env_vars: std::collections::BTreeMap::new(), + start_on_app_launch: false, + auto_restart_on_config_change: true, + runtime_pid: None, + backend: crate::managed_agents::types::BackendKind::Local, + backend_agent_id: None, + provider_binary_path: None, + team_id: None, + persona_team_dir: None, + persona_name_in_team: None, + 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: crate::managed_agents::types::RespondTo::OwnerOnly, + respond_to_allowlist: vec![], + 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, + agent_command_override: None, + persona_source_version: None, + provider: None, + } + } + + fn locked_envelope() -> (LockedSnapshotEnvelope, Keys, Keys) { + let (owner, agent) = owner_agent_keys(); + let env = + encrypt_snapshot_envelope(&sample_snapshot(), &owner, &agent.public_key()).unwrap(); + (env, owner, agent) + } + + #[test] + fn owner_secret_decrypts() { + let (env, owner, _agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, owner.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn agent_secret_decrypts() { + let (env, _owner, agent) = locked_envelope(); + let decoded = decrypt_envelope(&env, agent.secret_key()).unwrap(); + assert_eq!(decoded, sample_snapshot()); + } + + #[test] + fn unrelated_key_fails_closed_with_refusal_only() { + let (env, _owner, _agent) = locked_envelope(); + let stranger = Keys::generate(); + let err = decrypt_envelope(&env, stranger.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn tampered_ciphertext_fails_with_refusal_only() { + let (mut env, owner, _agent) = locked_envelope(); + // Flip a character mid-ciphertext (keep valid base64 alphabet). + let mid = env.encryption.ciphertext.len() / 2; + let mut bytes = env.encryption.ciphertext.into_bytes(); + bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' }; + env.encryption.ciphertext = String::from_utf8(bytes).unwrap(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn swapped_pubkeys_fail_closed_at_endpoint_resolution() { + let (mut env, owner, agent) = locked_envelope(); + std::mem::swap( + &mut env.encryption.owner_pubkey, + &mut env.encryption.agent_pubkey, + ); + // The NIP-44 conversation key is symmetric over the pair, so a swap + // cannot grant a stranger anything — but it desyncs the routing + // hints, and exact-endpoint resolution fails closed rather than + // guessing: the owner identity no longer matches `ownerPubkey`, and + // no local record holds the pubkey now in `agentPubkey`. + assert!(resolve_unlock_secret(&env, Some(&owner), &[]).is_none()); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + let record = record_with_keys(agent.public_key().to_hex(), nsec); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).is_none()); + } + + #[test] + fn mislabeled_pubkey_fails_decryption_with_refusal_only() { + // Replacing `agentPubkey` with a third party's key makes the owner + // derive the wrong conversation key — the NIP-44 MAC fails and only + // the refusal surfaces. + let (mut env, owner, _agent) = locked_envelope(); + env.encryption.agent_pubkey = Keys::generate().public_key().to_hex(); + let err = decrypt_envelope(&env, owner.secret_key()).unwrap_err(); + assert_eq!(err, LOCKED_CARD_REFUSAL); + } + + #[test] + fn malformed_pubkeys_rejected_structurally() { + let (env, _owner, _agent) = locked_envelope(); + + let mut short = env.clone(); + short.encryption.owner_pubkey = "abc123".to_string(); + assert!(validate_envelope(&short).unwrap_err().contains("malformed")); + + let mut upper = env.clone(); + upper.encryption.agent_pubkey = upper.encryption.agent_pubkey.to_uppercase(); + assert!(validate_envelope(&upper).unwrap_err().contains("malformed")); + + // A 64-hex string that is not a curve point (lift-x fails for + // x = p-1... all-f) must be rejected STRUCTURALLY — before any key + // lookup or decrypt work — per the wire contract. + let mut not_a_point = env.clone(); + not_a_point.encryption.agent_pubkey = "f".repeat(64); + assert!(validate_envelope(¬_a_point) + .unwrap_err() + .contains("not a curve point")); + + let mut same = env; + same.encryption.agent_pubkey = same.encryption.owner_pubkey.clone(); + assert!(validate_envelope(&same).unwrap_err().contains("differ")); + } + + #[test] + fn unknown_format_version_scheme_rejected() { + let (env, ..) = locked_envelope(); + + let mut bad_version = env.clone(); + bad_version.version = 2; + assert!(validate_envelope(&bad_version) + .unwrap_err() + .contains("version")); + + let mut bad_scheme = env.clone(); + bad_scheme.encryption.scheme = "nip44-v3".to_string(); + assert!(validate_envelope(&bad_scheme) + .unwrap_err() + .contains("scheme")); + + // Unknown top-level format never falls through to manifest parsing. + let unknown = serde_json::json!({"format": "buzz-agent-snapshot-v9", "version": 1}); + let err = parse_chunk_payload(unknown.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("Unsupported snapshot format"), "{err}"); + + let missing = serde_json::json!({"version": 1}); + let err = parse_chunk_payload(missing.to_string().as_bytes()).unwrap_err(); + assert!(err.contains("no format discriminator"), "{err}"); + } + + #[test] + fn plaintext_cap_enforced_before_encryption() { + let (owner, agent) = owner_agent_keys(); + let mut snapshot = sample_snapshot(); + // Inflate the manifest beyond the NIP-44 plaintext limit. + snapshot.definition.system_prompt = Some("x".repeat(NIP44_PLAINTEXT_MAX)); + let err = encrypt_snapshot_envelope(&snapshot, &owner, &agent.public_key()).unwrap_err(); + assert!(err.contains("too large to lock"), "{err}"); + } + + #[test] + fn ciphertext_and_envelope_caps_enforced_before_crypto() { + let (mut env, ..) = locked_envelope(); + env.encryption.ciphertext = "A".repeat(MAX_LOCKED_CIPHERTEXT_BYTES + 1); + assert!(validate_envelope(&env) + .unwrap_err() + .contains("maximum size")); + + // Oversized envelope JSON is rejected before typed deserialization. + let huge = format!( + r#"{{"format":"{LOCKED_FORMAT}","version":1,"pad":"{}","encryption":{{}}}}"#, + "p".repeat(MAX_LOCKED_ENVELOPE_JSON_BYTES) + ); + let err = parse_chunk_payload(huge.as_bytes()).unwrap_err(); + assert!(err.contains("maximum size"), "{err}"); + } + + #[test] + fn locked_png_round_trips_through_chunk_and_decrypt() { + let (owner, agent) = owner_agent_keys(); + let snapshot = sample_snapshot(); + let png = encode_locked_snapshot_png(&snapshot, &owner, &agent.public_key(), None).unwrap(); + + let payload = extract_chunk_payload_png(&png).unwrap(); + let ChunkPayload::Locked(env) = parse_chunk_payload(&payload).unwrap() else { + panic!("locked PNG must parse as a locked envelope"); + }; + // Both endpoints decrypt to the same logical manifest (compare + // manifests, never ciphertext — the NIP-44 nonce is random). + assert_eq!( + decrypt_envelope(&env, owner.secret_key()).unwrap(), + snapshot + ); + assert_eq!( + decrypt_envelope(&env, agent.secret_key()).unwrap(), + snapshot + ); + } + + #[test] + fn plain_manifest_dispatches_to_plain() { + let json = encode_snapshot_json(&sample_snapshot()).unwrap(); + let ChunkPayload::Plain(decoded) = parse_chunk_payload(&json).unwrap() else { + panic!("plain manifest must parse as Plain"); + }; + assert_eq!(*decoded, sample_snapshot()); + } + + #[test] + fn resolve_unlock_secret_owner_exact_endpoint() { + let (env, owner, _agent) = locked_envelope(); + let secret = resolve_unlock_secret(&env, Some(&owner), &[]).unwrap(); + assert_eq!(&secret, owner.secret_key()); + + // A different identity key is NOT tried. + let other = Keys::generate(); + assert!(resolve_unlock_secret(&env, Some(&other), &[]).is_none()); + assert!(resolve_unlock_secret(&env, None, &[]).is_none()); + } + + #[test] + fn resolve_unlock_secret_agent_requires_record_and_derived_match() { + let (env, _owner, agent) = locked_envelope(); + let nsec = nostr::ToBech32::to_bech32(agent.secret_key()).unwrap(); + + let record = record_with_keys(agent.public_key().to_hex(), nsec); + let secret = resolve_unlock_secret(&env, None, std::slice::from_ref(&record)).unwrap(); + assert_eq!(&secret, agent.secret_key()); + + // Record pubkey matches but the stored secret derives a DIFFERENT + // pubkey → refused (no trial decryption on mismatched material). + let mut forged = record.clone(); + forged.private_key_nsec = + nostr::ToBech32::to_bech32(Keys::generate().secret_key()).unwrap(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&forged)).is_none()); + + // Record for some other agent → not an endpoint. + let mut unrelated = record; + unrelated.pubkey = Keys::generate().public_key().to_hex(); + assert!(resolve_unlock_secret(&env, None, std::slice::from_ref(&unrelated)).is_none()); + } +} diff --git a/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs new file mode 100644 index 0000000000..b4492418e5 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/agent_snapshot_tests.rs @@ -0,0 +1,599 @@ +//! Unit tests for `managed_agents/agent_snapshot.rs`. +//! +//! Kept in a sibling file so `agent_snapshot.rs` stays under the +//! 1000-line gate; `#[path]`-included from there. + +use super::*; +use crate::managed_agents::types::{BackendKind, ManagedAgentRecord, RespondTo}; +use std::collections::BTreeMap; + +/// Build a minimal `ManagedAgentRecord` for testing. Only the fields +/// relevant to snapshot export are filled; the rest use defaults. +fn minimal_record() -> ManagedAgentRecord { + ManagedAgentRecord { + pubkey: "deadbeef".to_string(), + name: "Test Agent".to_string(), + display_name: Some("Test Agent Display".to_string()), + persona_id: Some("SENTINEL_PERSONA_ID".to_string()), // MUST NOT appear in snapshot + team_id: Some("SENTINEL_TEAM_ID".to_string()), // MUST NOT appear in snapshot + private_key_nsec: "nsec1secret".to_string(), // MUST NOT appear in snapshot + auth_tag: Some("auth-tag-secret".to_string()), // MUST NOT appear in snapshot + relay_url: "wss://relay.example.com".to_string(), // MUST NOT appear in snapshot + avatar_url: Some("https://example.com/avatar.png".to_string()), + acp_command: "/usr/local/bin/acp".to_string(), // MUST NOT appear in snapshot + agent_command: "goose".to_string(), // MUST NOT appear in snapshot + agent_command_override: Some("goose-override".to_string()), // MUST NOT appear + agent_args: vec!["--arg".to_string()], // MUST NOT appear in snapshot + mcp_command: "mcp-server".to_string(), // MUST NOT appear in snapshot + turn_timeout_seconds: 120, // deprecated, MUST NOT appear + idle_timeout_seconds: Some(30), + max_turn_duration_seconds: Some(600), + parallelism: 2, + system_prompt: Some("You are a test agent.".to_string()), + model: Some("claude-opus-4".to_string()), + provider: Some("anthropic".to_string()), + persona_source_version: Some("v1.0".to_string()), // MUST NOT appear + env_vars: { + let mut m = BTreeMap::new(); + m.insert("API_KEY".to_string(), "secret123".to_string()); // MUST NOT appear + m + }, + start_on_app_launch: true, + auto_restart_on_config_change: true, + runtime_pid: Some(12345), // MUST NOT appear + backend: BackendKind::Provider { + // MUST NOT appear — carries a provider secret + id: "SENTINEL_BACKEND_ID".to_string(), + config: serde_json::json!({"api_key": "SENTINEL_BACKEND_SECRET"}), + }, + backend_agent_id: Some("SENTINEL_BACKEND_AGENT_ID".to_string()), // MUST NOT appear + provider_binary_path: Some("/usr/bin/SENTINEL_PROVIDER_BINARY".to_string()), // MUST NOT appear + persona_team_dir: Some(std::path::PathBuf::from("SENTINEL_TEAM_DIR")), // MUST NOT appear + persona_name_in_team: Some("SENTINEL_NAME_IN_TEAM".to_string()), // MUST NOT appear + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-02T00:00:00Z".to_string(), + last_started_at: Some("2024-01-03T00:00:00Z".to_string()), // MUST NOT appear + last_stopped_at: None, + last_exit_code: Some(0), // MUST NOT appear + last_error: Some("SENTINEL_LAST_ERROR".to_string()), // MUST NOT appear + last_error_code: Some(42), // MUST NOT appear + respond_to: RespondTo::default(), + respond_to_allowlist: vec!["pubkey1hex".to_string()], + slug: Some("test-agent".to_string()), + runtime: Some("goose".to_string()), + name_pool: vec!["Alice".to_string(), "Bob".to_string()], + is_builtin: false, + is_active: true, + shared: false, + source_team: Some("team-id-123".to_string()), // MUST NOT appear + source_team_persona_slug: Some("lep".to_string()), // MUST NOT appear + definition_respond_to: Some("allowlist".to_string()), + catalog_source: None, + definition_respond_to_allowlist: vec!["abc123def".to_string()], + definition_parallelism: Some(4), + relay_mesh: None, + } +} + +// ── Round-trip tests ────────────────────────────────────────────────────── + +#[test] +fn json_round_trip_config_only() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn json_round_trip_with_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "I am a test agent.".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/research".to_string(), + body: "Some research notes.".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + let parsed = decode_snapshot_json(&bytes).unwrap(); + assert_eq!(parsed, snapshot); +} + +#[test] +fn png_round_trip_no_memory() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); + assert_eq!(parsed.profile.display_name, snapshot.profile.display_name); + assert_eq!(parsed.memory.level, MemoryLevel::None); +} + +#[test] +fn png_round_trip_with_avatar_png() { + // Build a minimal PNG avatar. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + // Avatar should be inlined as a data URL. + assert!(snapshot + .profile + .avatar_data_url + .as_deref() + .unwrap_or("") + .starts_with("data:image/png;base64,")); + + let png_bytes = encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + assert_eq!(parsed.definition.name, snapshot.definition.name); +} + +/// Plain-card byte compatibility: `encode_snapshot_png` was refactored +/// through the shared `encode_chunk_payload_png` when locked cards were +/// added. Plain cards must emit byte-identical PNGs to the pre-envelope +/// encoder. This vector reimplements the legacy encoder body verbatim and +/// asserts equality on all three composition paths: placeholder (no avatar), +/// PNG-avatar (where tEXt chunk injection ordering matters), and +/// JPEG-avatar transcode. +#[test] +fn plain_encoder_bytes_identical_to_pre_envelope_encoder() { + // Verbatim pre-refactor `encode_snapshot_png` body (post memory guard). + fn legacy_encode( + snapshot: &AgentSnapshot, + avatar_bytes: Option<&[u8]>, + ) -> Result, String> { + let json_bytes = encode_snapshot_json(snapshot)?; + let chunk_text = STANDARD.encode(&json_bytes); + let png_bytes = match avatar_bytes.filter(|bytes| !bytes.is_empty()) { + Some(bytes) => { + let encoded_avatar = if bytes.starts_with(b"\x89PNG") { + inject_text_chunk(bytes, PNG_CHUNK_KEYWORD, &chunk_text).or_else(|_| { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }) + } else { + transcode_avatar_to_png_with_text(bytes, PNG_CHUNK_KEYWORD, &chunk_text) + }; + match encoded_avatar { + Ok(png_bytes) => png_bytes, + Err(_) => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + } + } + None => make_png_with_text(PNG_CHUNK_KEYWORD, &chunk_text)?, + }; + Ok(png_bytes) + } + + let record = minimal_record(); + + // Placeholder path (no avatar). + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!( + encode_snapshot_png(&snapshot, None).unwrap(), + legacy_encode(&snapshot, None).unwrap(), + "placeholder-path plain PNG bytes must match the pre-envelope encoder" + ); + + // PNG-avatar path: chunk injected into the avatar image body. + let avatar = make_png_with_text("dummy", "value").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&avatar)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&avatar)).unwrap(), + legacy_encode(&snapshot, Some(&avatar)).unwrap(), + "avatar-path plain PNG bytes must match the pre-envelope encoder" + ); + + // JPEG-avatar path: transcode-to-PNG composition. + let jpeg_avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 4, + 4, + image::Rgb([0x10, 0x20, 0x30]), + )); + let mut jpeg_bytes = Vec::new(); + jpeg_avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&jpeg_bytes)); + assert_eq!( + encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(), + legacy_encode(&snapshot, Some(&jpeg_bytes)).unwrap(), + "transcode-path plain PNG bytes must match the pre-envelope encoder" + ); +} + +#[test] +fn png_snapshot_transcodes_jpeg_avatar_into_image_body() { + let avatar = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 3, + 2, + image::Rgb([0x12, 0x34, 0x56]), + )); + let mut jpeg_bytes = Vec::new(); + avatar + .write_to(&mut Cursor::new(&mut jpeg_bytes), image::ImageFormat::Jpeg) + .unwrap(); + + let snapshot = build_snapshot( + &minimal_record(), + MemoryLevel::None, + vec![], + Some(&jpeg_bytes), + ); + let png_bytes = encode_snapshot_png(&snapshot, Some(&jpeg_bytes)).unwrap(); + let decoder = Decoder::new(Cursor::new(png_bytes)); + let reader = decoder.read_info().unwrap(); + + assert_eq!((reader.info().width, reader.info().height), (3, 2)); +} + +// ── PNG memory parity ───────────────────────────────────────────────────── + +#[test] +fn png_round_trip_with_core_memory() { + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }]; + let snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_round_trip_with_everything_memory() { + let record = minimal_record(); + let entries = vec![ + AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "remember this".to_string(), + }, + AgentSnapshotMemoryEntry { + slug: "mem/notes".to_string(), + body: "private notes".to_string(), + }, + ]; + let snapshot = build_snapshot(&record, MemoryLevel::Everything, entries, None); + + let png_bytes = encode_snapshot_png(&snapshot, None).unwrap(); + let parsed = decode_snapshot_png(&png_bytes).unwrap(); + + assert_eq!(parsed.memory, snapshot.memory); +} + +#[test] +fn png_export_with_no_memory_succeeds() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert!(encode_snapshot_png(&snapshot, None).is_ok()); +} + +#[test] +fn png_export_rejects_none_level_with_nonempty_entries() { + // Inconsistent state: level == None but entries is non-empty. + // The encoder must reject this to prevent a memory-leak bypass. + let record = minimal_record(); + let entries = vec![AgentSnapshotMemoryEntry { + slug: "core".to_string(), + body: "leaked memory".to_string(), + }]; + // Build with entries, then override level to None in the struct. + let mut snapshot = build_snapshot(&record, MemoryLevel::Core, entries, None); + snapshot.memory.level = MemoryLevel::None; // force inconsistency + let result = encode_snapshot_png(&snapshot, None); + assert!( + result.is_err(), + "PNG encoder must reject level=None with non-empty entries" + ); + assert!( + result + .unwrap_err() + .contains("memory.level 'none' and non-empty memory entries"), + "Error must explain the malformed memory state" + ); +} + +// ── Secret exclusion tests ──────────────────────────────────────────────── +// +// These tests assert that every field in the exclusion list is absent from +// the serialized snapshot. We serialize to JSON and assert the key is NOT +// present. + +fn snapshot_json_string(record: &ManagedAgentRecord) -> String { + let snapshot = build_snapshot(record, MemoryLevel::None, vec![], None); + let bytes = encode_snapshot_json(&snapshot).unwrap(); + String::from_utf8(bytes).unwrap() +} + +#[test] +fn secret_exclusion_private_key_nsec_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("nsec1secret"), + "nsec must not appear in snapshot" + ); + assert!( + !json.contains("privateKeyNsec") && !json.contains("private_key_nsec"), + "privateKeyNsec field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_auth_tag_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("auth-tag-secret"), + "auth_tag value must not appear in snapshot" + ); + assert!( + !json.contains("authTag") && !json.contains("auth_tag"), + "authTag field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_env_vars_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("API_KEY") && !json.contains("secret123"), + "env_vars content must not appear in snapshot" + ); + assert!( + !json.contains("envVars") && !json.contains("env_vars"), + "envVars field must not appear in snapshot" + ); +} + +#[test] +fn secret_exclusion_relay_url_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("wss://relay.example.com"), + "relay_url value must not appear in snapshot" + ); + assert!( + !json.contains("relayUrl") && !json.contains("relay_url"), + "relayUrl field must not appear in snapshot" + ); +} + +#[test] +fn snapshot_omits_removed_mcp_toolsets_config() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("mcpToolsets") && !json.contains("mcp_toolsets"), + "removed MCP toolsets config must not re-enter snapshots" + ); +} + +#[test] +fn secret_exclusion_machine_commands_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + // acp_command / agent_command / agent_command_override / agent_args / mcp_command + assert!( + !json.contains("/usr/local/bin/acp"), + "acp_command path must not appear" + ); + assert!( + !json.contains("acpCommand") && !json.contains("acp_command"), + "acpCommand field must not appear" + ); + assert!( + !json.contains("agentCommand") && !json.contains("agent_command"), + "agentCommand field must not appear" + ); + assert!( + !json.contains("mcpCommand") && !json.contains("mcp_command"), + "mcpCommand field must not appear" + ); +} + +#[test] +fn secret_exclusion_runtime_state_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("runtimePid") && !json.contains("runtime_pid"), + "runtimePid must not appear" + ); + assert!( + !json.contains("backendAgentId") && !json.contains("backend_agent_id"), + "backendAgentId must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_AGENT_ID"), + "backendAgentId value must not appear" + ); + assert!( + !json.contains("providerBinaryPath") && !json.contains("provider_binary_path"), + "providerBinaryPath must not appear" + ); + assert!( + !json.contains("SENTINEL_PROVIDER_BINARY"), + "providerBinaryPath value must not appear" + ); + assert!( + !json.contains("lastStartedAt") && !json.contains("last_started_at"), + "lastStartedAt must not appear" + ); + assert!( + !json.contains("lastExitCode") && !json.contains("last_exit_code"), + "lastExitCode must not appear" + ); + // backend blob — neither the type tag nor provider secret must leak. + assert!( + !json.contains("\"backend\"") && !json.contains("backend"), + "backend field must not appear" + ); + assert!( + !json.contains("SENTINEL_BACKEND_ID") && !json.contains("SENTINEL_BACKEND_SECRET"), + "backend config values must not appear" + ); + // last_error / last_error_code + assert!( + !json.contains("lastError") && !json.contains("last_error"), + "lastError must not appear" + ); + assert!( + !json.contains("SENTINEL_LAST_ERROR"), + "lastError value must not appear" + ); + assert!( + !json.contains("lastErrorCode") && !json.contains("last_error_code"), + "lastErrorCode must not appear" + ); +} + +#[test] +fn secret_exclusion_lineage_ids_absent() { + let record = minimal_record(); + let json = snapshot_json_string(&record); + assert!( + !json.contains("team-id-123"), + "source_team value must not appear" + ); + assert!( + !json.contains("sourceTeam") && !json.contains("source_team"), + "sourceTeam field must not appear" + ); + assert!( + !json.contains("sourceTeamPersonaSlug"), + "sourceTeamPersonaSlug must not appear" + ); + assert!( + !json.contains("personaSourceVersion") && !json.contains("persona_source_version"), + "personaSourceVersion must not appear" + ); + // personaId + assert!( + !json.contains("personaId") && !json.contains("persona_id"), + "personaId field must not appear" + ); + assert!( + !json.contains("SENTINEL_PERSONA_ID"), + "personaId value must not appear" + ); + // teamId + assert!( + !json.contains("teamId") && !json.contains("team_id"), + "teamId field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_ID"), + "teamId value must not appear" + ); + // personaTeamDir + assert!( + !json.contains("personaTeamDir") && !json.contains("persona_team_dir"), + "personaTeamDir field must not appear" + ); + assert!( + !json.contains("SENTINEL_TEAM_DIR"), + "personaTeamDir value must not appear" + ); + // personaNameInTeam + assert!( + !json.contains("personaNameInTeam") && !json.contains("persona_name_in_team"), + "personaNameInTeam field must not appear" + ); + assert!( + !json.contains("SENTINEL_NAME_IN_TEAM"), + "personaNameInTeam value must not appear" + ); +} + +// ── Definition field presence tests ────────────────────────────────────── + +#[test] +fn definition_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + + assert_eq!(snapshot.definition.name, "Test Agent Display"); + assert!(!snapshot.definition.source_is_builtin); + assert_eq!( + snapshot.definition.system_prompt.as_deref(), + Some("You are a test agent.") + ); + assert_eq!(snapshot.definition.runtime.as_deref(), Some("goose")); + assert_eq!(snapshot.definition.model.as_deref(), Some("claude-opus-4")); + assert_eq!(snapshot.definition.provider.as_deref(), Some("anthropic")); + assert_eq!(snapshot.definition.name_pool, vec!["Alice", "Bob"]); + // definition_respond_to maps to respond_to in the snapshot definition + assert_eq!(snapshot.definition.respond_to.as_deref(), Some("allowlist")); + // definition_respond_to_allowlist should be included + assert!(!snapshot.definition.respond_to_allowlist.is_empty()); +} + +#[test] +fn profile_fields_present_in_snapshot() { + let record = minimal_record(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], None); + assert_eq!(snapshot.profile.display_name, "Test Agent Display"); + // No bytes → should fall back to avatar_url + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/avatar.png") + ); + assert!(snapshot.profile.avatar_data_url.is_none()); +} + +#[test] +fn avatar_inlined_when_under_size_limit() { + let record = minimal_record(); + let small_png = make_png_with_text("k", "v").unwrap(); + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&small_png)); + assert!(snapshot.profile.avatar_data_url.is_some()); + assert!(snapshot.profile.avatar_url.is_none()); +} + +#[test] +fn avatar_url_fallback_when_over_size_limit() { + let mut record = minimal_record(); + record.avatar_url = Some("https://example.com/big.png".to_string()); + // Synthesize oversized avatar bytes (> 2 MB) — just a large zeroed vec. + let big_bytes = vec![0u8; MAX_AVATAR_INLINE_BYTES + 1]; + let snapshot = build_snapshot(&record, MemoryLevel::None, vec![], Some(&big_bytes)); + assert!(snapshot.profile.avatar_data_url.is_none()); + assert_eq!( + snapshot.profile.avatar_url.as_deref(), + Some("https://example.com/big.png") + ); +} + +// ── Format/version validation ───────────────────────────────────────────── + +#[test] +fn invalid_format_discriminator_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.format = "not-a-buzz-snapshot".to_string(); + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot format")); +} + +#[test] +fn unsupported_version_is_rejected() { + let mut snapshot = build_snapshot(&minimal_record(), MemoryLevel::None, vec![], None); + snapshot.version = 99; + let bytes = serde_json::to_vec(&snapshot).unwrap(); + let result = decode_snapshot_json(&bytes); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Unsupported snapshot version")); +} diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index be9b07cf11..772d707f27 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,6 +1,7 @@ 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 agent_env::{ baked_build_env, build_buzz_agent_provider_defaults, discovery_env_with_baked_floor, diff --git a/desktop/src/features/agents/cardMintStore.test.mjs b/desktop/src/features/agents/cardMintStore.test.mjs new file mode 100644 index 0000000000..eb9c0f07b0 --- /dev/null +++ b/desktop/src/features/agents/cardMintStore.test.mjs @@ -0,0 +1,170 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +// cardMintStore drives the non-blocking mint flow: the dialog dispatches a +// job and closes; the composer chip, completion toast, and viewer all read +// this store. These tests exercise the job lifecycle with an injected mintFn +// (never the real Tauri command). +// +// The store imports sonner (toast); calling toast outside a mounted +// only queues, so no DOM is required. + +import { + dismissCardMintJob, + getCardGalleryOpen, + getCardMintJobs, + getCardViewerState, + closeCardViewer, + openCardViewer, + resetCardMintStore, + runCardMintJob, + setCardGalleryOpen, + subscribeCardMintStore, + viewMintedCardJob, +} from "./cardMintStore.ts"; + +const CARD = { + cardPngBase64: "aGVsbG8=", + fileName: "eva.agent.png", + designerNotes: "notes", + locked: false, + memoryLevel: "none", +}; + +const INPUT = { agentId: "agent-1", agentName: "Eva" }; + +describe("cardMintStore", () => { + beforeEach(() => { + resetCardMintStore(); + }); + + it("forwards the mint input — including memoryLevel — to mintFn", async () => { + const seen = []; + await runCardMintJob( + { ...INPUT, styleNotes: "stormy", lock: true, memoryLevel: "core" }, + (...args) => { + seen.push(args); + return Promise.resolve({ ...CARD, memoryLevel: "core" }); + }, + ); + assert.deepEqual(seen, [["agent-1", "stormy", true, "core"]]); + + // Omitted memoryLevel stays undefined so Rust applies its "none" default. + await runCardMintJob(INPUT, (...args) => { + seen.push(args); + return Promise.resolve(CARD); + }); + assert.deepEqual(seen[1], ["agent-1", undefined, undefined, undefined]); + }); + + it("tracks a successful mint through minting → done", async () => { + let resolveMint; + const pending = new Promise((resolve) => { + resolveMint = resolve; + }); + const run = runCardMintJob(INPUT, () => pending); + + let jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "minting"); + assert.equal(jobs[0].input.agentName, "Eva"); + assert.equal(jobs[0].card, null); + + resolveMint(CARD); + await run; + + jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "done"); + assert.deepEqual(jobs[0].card, CARD); + assert.equal(jobs[0].error, null); + }); + + it("records the error message on a failed mint", async () => { + await runCardMintJob(INPUT, () => Promise.reject(new Error("boom"))); + const jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].phase, "error"); + assert.equal(jobs[0].error, "boom"); + assert.equal(jobs[0].card, null); + }); + + it("strips the NO_OPENAI_KEY wire prefix from mint errors", async () => { + await runCardMintJob(INPUT, () => + Promise.reject(new Error("NO_OPENAI_KEY: No OPENAI_API_KEY found.")), + ); + assert.equal(getCardMintJobs()[0].error, "No OPENAI_API_KEY found."); + }); + + it("viewMintedCardJob moves a done job into the viewer and clears the chip", async () => { + await runCardMintJob(INPUT, () => Promise.resolve(CARD)); + const jobId = getCardMintJobs()[0].jobId; + + viewMintedCardJob(jobId); + + assert.equal(getCardMintJobs().length, 0); + const viewer = getCardViewerState(); + assert.ok(viewer); + assert.equal(viewer.agentName, "Eva"); + assert.deepEqual(viewer.card, CARD); + // Fresh mints keep their input so the viewer can reroll. + assert.deepEqual(viewer.remint, INPUT); + }); + + it("viewMintedCardJob ignores jobs that are still minting", () => { + let resolveMint; + void runCardMintJob( + INPUT, + () => new Promise((resolve) => (resolveMint = resolve)), + ); + const jobId = getCardMintJobs()[0].jobId; + + viewMintedCardJob(jobId); + + assert.equal(getCardMintJobs().length, 1); + assert.equal(getCardViewerState(), null); + resolveMint(CARD); // avoid a dangling promise + }); + + it("dismissCardMintJob removes only the named job", async () => { + await runCardMintJob(INPUT, () => Promise.reject(new Error("a"))); + await runCardMintJob({ agentId: "agent-2", agentName: "Wren" }, () => + Promise.reject(new Error("b")), + ); + const [first, second] = getCardMintJobs(); + + dismissCardMintJob(first.jobId); + + const jobs = getCardMintJobs(); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].jobId, second.jobId); + }); + + it("openCardViewer/closeCardViewer manage archive views without remint", () => { + openCardViewer({ card: CARD, agentName: "Eva", remint: null }); + assert.equal(getCardViewerState()?.remint, null); + closeCardViewer(); + assert.equal(getCardViewerState(), null); + }); + + it("gallery open flag toggles and notifies subscribers", () => { + let notified = 0; + const unsubscribe = subscribeCardMintStore(() => { + notified += 1; + }); + setCardGalleryOpen(true); + assert.equal(getCardGalleryOpen(), true); + setCardGalleryOpen(true); // no-op must not notify + setCardGalleryOpen(false); + assert.equal(getCardGalleryOpen(), false); + assert.equal(notified, 2); + unsubscribe(); + }); + + it("concurrent jobs keep distinct snapshots (referential updates)", async () => { + const before = getCardMintJobs(); + await runCardMintJob(INPUT, () => Promise.resolve(CARD)); + const after = getCardMintJobs(); + assert.notEqual(before, after, "snapshot identity must change on update"); + }); +}); diff --git a/desktop/src/features/agents/cardMintStore.ts b/desktop/src/features/agents/cardMintStore.ts new file mode 100644 index 0000000000..0e4746d445 --- /dev/null +++ b/desktop/src/features/agents/cardMintStore.ts @@ -0,0 +1,227 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { + mintAgentCard, + NO_OPENAI_KEY_PREFIX, + type MintedAgentCard, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; + +/** + * Module store for agent-card mints (`useSyncExternalStore` pattern, same as + * `avatarPresentationStore`). + * + * A mint is one stateless ~2–3 minute Rust call. Owning the in-flight promise + * here — instead of inside the mint dialog — is what makes the dialog + * non-blocking: it dispatches and closes, the composer activity rail shows a + * live "Minting card…" chip, and completion lands as a clickable toast plus a + * persistent "card ready" chip, none of which need the dialog mounted. + */ + +/** Everything needed to run (or re-run) one mint. */ +export type CardMintInput = { + agentId: string; + agentName: string; + styleNotes?: string; + lock?: boolean; + /** Memory to embed in the card's snapshot. Omitted = "none". */ + memoryLevel?: SnapshotMemoryLevel; +}; + +export type CardMintJob = { + jobId: string; + input: CardMintInput; + phase: "minting" | "done" | "error"; + /** Populated when phase is "done". */ + card: MintedAgentCard | null; + /** Populated when phase is "error". */ + error: string | null; + startedAt: number; +}; + +/** Card content shown by the global viewer dialog. */ +export type CardViewerState = { + card: MintedAgentCard; + agentName: string; + /** + * Present when the card can be rerolled (fresh mints carry their input; + * archive views do not — the original style notes are gone). + */ + remint: CardMintInput | null; + /** + * Monotonic per-open sequence, assigned by the store. The viewer keys its + * content on this so switching cards remounts (resetting recipients/menu + * state) — card bytes can't serve as the key because every card PNG shares + * the same header prefix and dimensions. + */ + viewerSeq: number; +}; + +let jobs: CardMintJob[] = []; +let viewer: CardViewerState | null = null; +let galleryOpen = false; +const listeners = new Set<() => void>(); +let nextJobId = 1; +let nextViewerSeq = 1; + +function emitChange(): void { + for (const listener of listeners) listener(); +} + +function updateJob(jobId: string, patch: Partial): void { + jobs = jobs.map((job) => (job.jobId === jobId ? { ...job, ...patch } : job)); + emitChange(); +} + +/** + * Run one mint as a background job. `mintFn` is injectable for tests; the + * public `startCardMint` binds the real Tauri command. + */ +export async function runCardMintJob( + input: CardMintInput, + mintFn: ( + id: string, + styleNotes?: string, + lock?: boolean, + memoryLevel?: SnapshotMemoryLevel, + ) => Promise, +): Promise { + const jobId = `card-mint-${nextJobId++}`; + jobs = [ + ...jobs, + { + jobId, + input, + phase: "minting", + card: null, + error: null, + startedAt: Date.now(), + }, + ]; + emitChange(); + + try { + const card = await mintFn( + input.agentId, + input.styleNotes, + input.lock, + input.memoryLevel, + ); + updateJob(jobId, { phase: "done", card }); + toast.success(`${input.agentName}'s card is ready`, { + action: { + label: "View card", + onClick: () => viewMintedCardJob(jobId), + }, + duration: 10_000, + }); + } catch (error) { + let message = error instanceof Error ? error.message : "Card mint failed."; + if (message.startsWith(NO_OPENAI_KEY_PREFIX)) { + // The dialog pre-checks the key, so this only happens when the key was + // removed between dialog-open and mint. The dialog's key-setup panel is + // long gone — surface a plain instruction instead of the wire prefix. + message = message.slice(NO_OPENAI_KEY_PREFIX.length).trim(); + } + updateJob(jobId, { phase: "error", error: message }); + toast.error(`Minting ${input.agentName}'s card failed`, { + description: message, + }); + } +} + +/** Start a mint in the background. Fire-and-forget; state flows via the store. */ +export function startCardMint(input: CardMintInput): void { + void runCardMintJob(input, mintAgentCard); +} + +/** Open the finished card of a job in the viewer and clear its rail chip. */ +export function viewMintedCardJob(jobId: string): void { + const job = jobs.find((candidate) => candidate.jobId === jobId); + if (job?.phase !== "done" || !job.card) return; + viewer = { + card: job.card, + agentName: job.input.agentName, + remint: job.input, + viewerSeq: nextViewerSeq++, + }; + jobs = jobs.filter((candidate) => candidate.jobId !== jobId); + emitChange(); +} + +/** Remove a job chip (used for error dismissal). */ +export function dismissCardMintJob(jobId: string): void { + const next = jobs.filter((candidate) => candidate.jobId !== jobId); + if (next.length === jobs.length) return; + jobs = next; + emitChange(); +} + +/** Open the viewer on an arbitrary card (e.g. one loaded from the archive). */ +export function openCardViewer( + state: Omit, +): void { + viewer = { ...state, viewerSeq: nextViewerSeq++ }; + emitChange(); +} + +export function closeCardViewer(): void { + if (!viewer) return; + viewer = null; + emitChange(); +} + +export function setCardGalleryOpen(open: boolean): void { + if (galleryOpen === open) return; + galleryOpen = open; + emitChange(); +} + +export function resetCardMintStore(): void { + jobs = []; + viewer = null; + galleryOpen = false; + emitChange(); +} + +export function getCardMintJobs(): CardMintJob[] { + return jobs; +} + +export function getCardViewerState(): CardViewerState | null { + return viewer; +} + +export function getCardGalleryOpen(): boolean { + return galleryOpen; +} + +export function subscribeCardMintStore(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function useCardMintJobs(): CardMintJob[] { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardMintJobs, + getCardMintJobs, + ); +} + +export function useCardViewerState(): CardViewerState | null { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardViewerState, + getCardViewerState, + ); +} + +export function useCardGalleryOpen(): boolean { + return React.useSyncExternalStore( + subscribeCardMintStore, + getCardGalleryOpen, + getCardGalleryOpen, + ); +} diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs new file mode 100644 index 0000000000..ebc7caa8f4 --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardGalleryState.test.mjs @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +// Pins the gallery's query-state boundary: a rejected `list_agent_cards` +// MUST surface as an error state — never as "No cards yet". `data` falls +// back to `[]` at the call site, so an emptiness-first branch would render +// a permissions/IPC failure as a false empty archive of paid cards. + +import { agentCardGalleryViewState } from "./agentCardGalleryState.ts"; + +describe("agentCardGalleryViewState", () => { + it("maps a rejected query to an error state with the message, not empty", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: new Error("cards directory unreadable"), + data: undefined, + }); + assert.deepEqual(state, { + kind: "error", + message: "cards directory unreadable", + }); + }); + + it("error wins even when data has fallen back to an empty array", () => { + // react-query keeps `data: undefined` on first failure, but the call + // site coalesces to []. Guard the exact shape that caused the bug. + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: new Error("ipc failure"), + data: [], + }); + assert.equal(state.kind, "error"); + }); + + it("stringifies non-Error rejections", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: "permission denied", + data: undefined, + }); + assert.deepEqual(state, { kind: "error", message: "permission denied" }); + }); + + it("null/undefined rejections still produce a message", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: true, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "error", message: "unknown error" }); + }); + + it("loading while fetching", () => { + const state = agentCardGalleryViewState({ + isLoading: true, + isError: false, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "loading" }); + }); + + it("no data yet (not loading, not error) stays loading, not empty", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: undefined, + }); + assert.deepEqual(state, { kind: "loading" }); + }); + + it("resolved and empty is the only path to the empty state", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: [], + }); + assert.deepEqual(state, { kind: "empty" }); + }); + + it("resolved with cards renders cards", () => { + const state = agentCardGalleryViewState({ + isLoading: false, + isError: false, + error: null, + data: [{ storedFileName: "a.png" }], + }); + assert.deepEqual(state, { kind: "cards" }); + }); +}); diff --git a/desktop/src/features/agents/lib/agentCardGalleryState.ts b/desktop/src/features/agents/lib/agentCardGalleryState.ts new file mode 100644 index 0000000000..8f6cf4269e --- /dev/null +++ b/desktop/src/features/agents/lib/agentCardGalleryState.ts @@ -0,0 +1,35 @@ +/** + * View-state selection for the minted-card gallery. + * + * Kept as a pure function so the error boundary is testable without a DOM: + * a rejected `list_agent_cards` query MUST surface as an error state, never + * as an empty archive — "No cards yet" on a permissions/IPC failure tells + * someone their paid, persisted cards do not exist. + */ + +export type AgentCardGalleryViewState = + | { kind: "loading" } + | { kind: "error"; message: string } + | { kind: "empty" } + | { kind: "cards" }; + +export function agentCardGalleryViewState(query: { + isLoading: boolean; + isError: boolean; + error: unknown; + data: readonly unknown[] | undefined; +}): AgentCardGalleryViewState { + // Error wins over everything: `data` falls back to `[]` at the call site, + // so checking emptiness first would render a failure as a false empty. + if (query.isError) { + const message = + query.error instanceof Error + ? query.error.message + : String(query.error ?? "unknown error"); + return { kind: "error", message }; + } + if (query.isLoading || query.data === undefined) { + return { kind: "loading" }; + } + return query.data.length === 0 ? { kind: "empty" } : { kind: "cards" }; +} diff --git a/desktop/src/features/agents/ui/AgentCardMintDialog.tsx b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx new file mode 100644 index 0000000000..219de4aefa --- /dev/null +++ b/desktop/src/features/agents/ui/AgentCardMintDialog.tsx @@ -0,0 +1,364 @@ +import * as React from "react"; +import { + AlertCircle, + Brain, + ExternalLink, + GalleryVerticalEnd, + KeyRound, + Lock, + Sparkles, +} from "lucide-react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { openUrl } from "@tauri-apps/plugin-opener"; +import { toast } from "sonner"; + +import { + setCardGalleryOpen, + startCardMint, +} from "@/features/agents/cardMintStore"; +import { globalAgentConfigQueryKey } from "@/features/agents/useGlobalAgentConfig"; +import { + cardMintKeyStatus, + cardMintSaveOpenaiKey, + type SnapshotMemoryLevel, +} from "@/shared/api/tauriPersonas"; +import { Button } from "@/shared/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/shared/ui/dialog"; +import { Input } from "@/shared/ui/input"; +import { Switch } from "@/shared/ui/switch"; +import { Textarea } from "@/shared/ui/textarea"; +import { SnapshotOptionMenu } from "./SnapshotOptionMenu"; + +const OPENAI_KEYS_URL = "https://platform.openai.com/api-keys"; + +/** Same three levels as snapshot export; "Agent only" is the safe default. */ +const MEMORY_LEVELS: { value: SnapshotMemoryLevel; label: string }[] = [ + { value: "none", label: "Agent only" }, + { value: "core", label: "Agent + core memory" }, + { value: "everything", label: "Agent + all memories" }, +]; + +/** + * The free alternative, as an action: ordinary snapshot export shares the + * same importable agent without card art or API spend. Rendered in both the + * key-setup panel and the normal pre-mint form (the cost disclosure and its + * escape hatch must be visible BEFORE any spend, not only during onboarding). + */ +function FreeSharePathRow({ + disabled, + onExportInstead, +}: { + disabled: boolean; + onExportInstead?: () => void; +}) { + return ( +

+ ); +} + +/** + * Mint-a-trading-card dialog — the pre-mint half only: key setup (when + * needed) → optional style notes → "Mint card". Minting itself runs as a + * background job in `cardMintStore`: this dialog dispatches and closes, the + * composer activity rail shows live status, and the finished card opens in + * the global `AgentCardViewerDialog` (preview, reroll, save, share). + * + * The saved PNG carries the agent's `buzz_agent_snapshot` chunk, so sharing + * the card shares an importable agent (fresh identity, never secrets; memory + * only when the owner opts in below — plaintext unless the card is locked). + * All snapshot construction and verification happens in Rust. + */ +export function AgentCardMintDialog({ + agentId, + agentName, + canLock, + onExportInstead, + onOpenChange, +}: { + /** Instance pubkey or definition slug — same resolution as snapshot export. */ + agentId: string; + agentName: string; + /** + * True when the agent has a linked instance (a keypair to lock to). + * Locking is disabled — with an explanation — for bare definitions. + */ + canLock: boolean; + /** + * Free alternative: close this dialog and open the ordinary snapshot + * export flow (no API spend). Omitted = the action is not rendered. + */ + onExportInstead?: () => void; + onOpenChange: (open: boolean) => void; +}) { + const [styleNotes, setStyleNotes] = React.useState(""); + const [lockCard, setLockCard] = React.useState(false); + const [memoryLevel, setMemoryLevel] = + React.useState("none"); + const [keyDraft, setKeyDraft] = React.useState(""); + + const queryClient = useQueryClient(); + + const effectiveLock = canLock && lockCard; + // Embedded memory is plaintext in an unlocked card — and unlocked cards + // are meant to be shared. A locked card encrypts the whole manifest to the + // (owner, agent) pair, so the plaintext warning would be false there. + const showMemoryWarning = memoryLevel !== "none" && !effectiveLock; + + // Whether a key already resolves through the agent's env layering. While + // unknown (loading/error) we show the normal mint form — the mint itself + // still fails cleanly if no key exists. + const keyStatusQuery = useQuery({ + queryKey: ["cardMintKeyStatus", agentId], + queryFn: () => cardMintKeyStatus(agentId), + }); + const needsKey = keyStatusQuery.data === false; + + // Save the pasted key into the global Agent Defaults env — the same single + // source of truth every agent inherits. Narrow Rust seam: validated + // single-key merge, never restarts running agents (the mint re-reads + // config per call, so no restart is needed for minting). + const saveKeyMutation = useMutation({ + mutationFn: (key: string) => cardMintSaveOpenaiKey(key), + onSuccess: () => { + queryClient.setQueryData(["cardMintKeyStatus", agentId], true); + // The Agent Defaults editor caches the whole config — refetch it so a + // later-opened settings view shows the key we just wrote. + void queryClient.invalidateQueries({ + queryKey: globalAgentConfigQueryKey, + }); + setKeyDraft(""); + toast.success( + "API key saved to your agent defaults. Running agents pick it up on their next restart.", + ); + }, + onError: (error) => + toast.error(typeof error === "string" ? error : "Couldn't save the key."), + }); + + function beginMint() { + // Dispatch to the background store and close: the composer rail shows + // "Minting card…" and the completion toast opens the viewer. + startCardMint({ + agentId, + agentName, + styleNotes: styleNotes.trim() || undefined, + lock: effectiveLock, + memoryLevel: canLock ? memoryLevel : "none", + }); + onOpenChange(false); + } + + return ( + + + + + + {`Create ${agentName}'s card`} + + + Mint a collectible trading card that doubles as a shareable, + importable copy of this agent. + + + + {needsKey ? ( +
+
+ + + One-time setup: OpenAI API key + +

+ Minting a card costs money — it generates the art and card text + through the OpenAI API with your key (typically well under a + dollar per mint, billed by OpenAI). The key is saved to your + agent defaults, so you only do this once. +

+ + setKeyDraft(e.target.value)} + placeholder="sk-…" + type="password" + value={keyDraft} + /> +
+ +
+ +
+
+ ) : ( +
+
+