diff --git a/Justfile b/Justfile index 6df2aff7976..f61e0ef8f50 100644 --- a/Justfile +++ b/Justfile @@ -442,8 +442,10 @@ test-unit: # non-postgres_tests cases only "pass" without a database by waiting out # the ~30s sqlx acquire timeout, so they do not belong in the infra-free # unit job either. + # The author-only fanout family uses lazy pools and in-memory recipients; + # include Stop request/result privacy and its sibling private kinds. cargo nextest run -p buzz-relay --lib \ - -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/)' + -E '(test(/^api::admin::/) - test(=api::admin::tests::disabled_mode_allows_unauthenticated_requests_on_the_admin_host) - test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)) + test(/^handlers::channel_authz::/) + test(/^handlers::moderation_authz::/) + test(/^handlers::side_effects::tests::/) + test(/^handlers::event::tests::fanout_access::.*_delivers_to_author_only$/)' # ACP author-gate and queue tests protect the trust boundary between # relay events and agent prompts. They are infra-free; ignored lifecycle # tests remain excluded and run in their dedicated integration lanes. diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 4a27ee671a6..4bd402673f1 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -20,6 +20,7 @@ export default defineConfig({ name: "smoke", testMatch: [ "**/smoke.spec.ts", + "**/desktop-stop.spec.ts", "**/owned-agent-discovery.spec.ts", "**/thread-head-stale-edit.spec.ts", "**/sidebar-offcanvas-rail.spec.ts", diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 63ad3ee07a9..020be239764 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -306,9 +306,10 @@ with a TypeScript lookup table or an id comparison in a component. ## Remote Desktop Stop -Native IPC accepts an owner-private, explicitly selected agent+Desktop Stop, -not inferred agent location. The relay redelivers stored Stop duplicates without -repeating relay side effects. +Known Desktops exposes an owner-private, explicitly selected agent+Desktop Stop, +not inferred agent location. The app-scoped receiver subscribes live only; +reopening never replays commands. An explicit retry republishes the exact request; +the relay redelivers stored Stop duplicates without repeating relay side effects. The receiver returns saved results or Unknown, never repeats a consumed Stop. Native owner-delegation and community checks precede durable admission and ordinary pair Stop. A delivery ACK is not success. diff --git a/desktop/src/features/agents/ui/DesktopStopControl.test.mjs b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs new file mode 100644 index 00000000000..d7d8b1dfc1f --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import React from "react"; +import { JSDOM } from "jsdom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + DesktopStopControl, + DesktopStopReceiver, +} from "./DesktopStopControl.tsx"; +import { relayClient } from "../../../shared/api/relayClient.ts"; + +test("mounted Stop waits for a correlated result and retries identical bytes without replay", async () => { + const dom = new JSDOM("
", { + url: "https://desktop.test", + }); + Object.assign(globalThis, { + window: dom.window, + document: dom.window.document, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }); + const { createRoot } = await import("react-dom/client"); + const scope = { owner: "owner", community: "wss://one.example" }; + const request = { id: "request", kind: 50180, tags: [["d", "desktop"]] }; + const result = { id: "result", kind: 50181, tags: [["e", request.id]] }; + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: Infinity } }, + }); + client.setQueryData( + ["relay-agents"], + [ + { pubkey: "agent", name: "Owned agent", ownerPubkey: "owner" }, + { pubkey: "foreign", name: "Foreign agent", ownerPubkey: "other" }, + ], + ); + const original = { + fetch: relayClient.fetchEvents, + publish: relayClient.publishEvent, + subscribe: relayClient.subscribeLive, + }; + let live, release; + let receiveCalls = 0, + prepared = 0, + closed = 0; + const sent = []; + window.__TAURI_INTERNALS__ = { + invoke: async (command, args) => { + assert.equal(args.owner, scope.owner); + assert.equal(args.community, scope.community); + if (command === "prepare_desktop_stop") { + prepared++; + assert.equal(args.desktop, "desktop"); + assert.equal(args.agent, "agent"); + return request; + } + if (command === "receive_desktop_stop") { + receiveCalls++; + return result; + } + assert.equal(command, "read_desktop_stop_results"); + return "stopped"; + }, + }; + relayClient.subscribeLive = async (filter, callback) => { + assert.deepEqual(filter, { + kinds: [50180], + authors: [scope.owner], + limit: 0, + }); + live = callback; + return () => { + closed++; + live = undefined; + }; + }; + relayClient.publishEvent = async (event) => { + sent.push(event); + live?.(event); + }; + relayClient.fetchEvents = async (filter) => { + assert.deepEqual(filter, { + kinds: [50181], + authors: [scope.owner], + "#e": [request.id], + limit: 16, + }); + return new Promise((resolve) => { + release = () => resolve([result]); + }); + }; + const root = createRoot(document.getElementById("root")); + const render = (receiver) => + React.act(async () => + root.render( + React.createElement( + QueryClientProvider, + { client }, + React.createElement( + React.Fragment, + null, + receiver + ? React.createElement(DesktopStopReceiver, { scope }) + : null, + React.createElement(DesktopStopControl, { + scope, + desktop: { id: "desktop", name: "Workshop" }, + }), + ), + ), + ), + ); + const click = (text) => + React.act(async () => + [...document.querySelectorAll("button")] + .find((b) => b.textContent === text) + .click(), + ); + try { + await render(false); + assert.doesNotMatch(document.body.textContent, /Foreign agent/); + const select = document.querySelector("select"); + await React.act(async () => { + select.value = "agent"; + select.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + }); + await click("Stop on Workshop"); + assert.equal(prepared, 1); + assert.match(document.body.textContent, /Waiting for this Desktop/); + assert.doesNotMatch(document.body.textContent, /Stop confirmed/); + assert.equal(receiveCalls, 0, "absent receiver has not stopped anything"); + await React.act(async () => release()); + assert.match(document.body.textContent, /Stop confirmed by Workshop/); + await render(true); + assert.equal(receiveCalls, 0, "mount cannot replay stored Stop"); + // The mounted receiver returns a saved native result, while the sender + // explicitly retries the exact prepared request rather than signing anew. + relayClient.publishEvent = async (event) => { + sent.push(event); + if (event.kind === 50180) live?.(event); + }; + await click("Retry same Stop"); + await React.act(async () => release()); + assert.equal(prepared, 1); + assert.equal(receiveCalls, 1); + assert.ok(sent.filter((e) => e.kind === 50180).every((e) => e === request)); + } finally { + await React.act(async () => root.unmount()); + assert.equal(closed, 1); + client.clear(); + relayClient.fetchEvents = original.fetch; + relayClient.publishEvent = original.publish; + relayClient.subscribeLive = original.subscribe; + dom.window.close(); + } +}); diff --git a/desktop/src/features/agents/ui/DesktopStopControl.tsx b/desktop/src/features/agents/ui/DesktopStopControl.tsx new file mode 100644 index 00000000000..bd5e8f56cd7 --- /dev/null +++ b/desktop/src/features/agents/ui/DesktopStopControl.tsx @@ -0,0 +1,170 @@ +import { useEffect, useRef, useState } from "react"; +import { useRelayAgentsQuery } from "../hooks"; +import { + prepareStop, + readStopOutcome, + receiveStops, + sendStop, +} from "../desktopStop"; +import type { DesktopScope, DesktopRow } from "../desktopList"; +import type { RelayEvent } from "@/shared/api/types"; +import { Button } from "@/shared/ui/button"; + +/** App-scoped live receiver; no historical requests are loaded on mount. */ +export function DesktopStopReceiver({ scope }: { scope: DesktopScope | null }) { + const [error, setError] = useState(""); + const { owner, community } = scope ?? {}; + useEffect(() => { + if (!owner || !community) return; + let active = true; + let close: (() => void) | undefined; + setError(""); + void receiveStops({ owner, community }, () => active, setError) + .then((unsubscribe) => { + if (active) close = unsubscribe; + else unsubscribe(); + }) + .catch(() => { + if (active) + setError("Remote Stop receiver is unavailable on this Desktop."); + }); + return () => { + active = false; + close?.(); + }; + }, [owner, community]); + return error ? ( ++ {error} +
+ ) : null; +} + +/** Deliberately selects a host, not an inferred running location or presence. */ +export function DesktopStopControl({ + scope, + desktop, +}: { + scope: DesktopScope; + desktop: DesktopRow; +}) { + const agents = useRelayAgentsQuery(); + const owned = (agents.data ?? []).filter( + (agent) => agent.ownerPubkey === scope.owner, + ); + const [agent, setAgent] = useState(""); + const [request, setRequest] = useState+ Stops only this agent on this Desktop in this community. This list does + not establish where it is running. +
+ {agents.isError &&Your agent list is unavailable.
} + + {request && ( + + )} + {message && ( ++ {message} +
+ )} +