diff --git a/desktop/package.json b/desktop/package.json index 61e1969a0d6..5707f88dd74 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -86,6 +86,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@noble/curves": "2.0.1", "@noble/hashes": "^2.0.1", "@playwright/test": "^1.58.2", "@tailwindcss/postcss": "^4.3.0", diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index c938298f488..19b200337b3 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -177,6 +177,7 @@ export default defineConfig({ "**/integration.spec.ts", "**/dm-double-notification.spec.ts", "**/evidence-reactions-relay.spec.ts", + "**/bridge-relay-mutations.spec.ts", "**/profile.spec.ts", "**/sidebar.spec.ts", "**/sidebar-relay-card.spec.ts", diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 70c525785c2..1eabeb1d5be 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -1,3 +1,5 @@ +import { schnorr } from "@noble/curves/secp256k1.js"; +import { sha256 } from "@noble/hashes/sha2.js"; import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; import { emit, listen } from "@tauri-apps/api/event"; import { mockIPC, mockWindows } from "@tauri-apps/api/mocks"; @@ -111,6 +113,13 @@ export type MockManagedAgentSeed = { envVars?: Record; /** Hermes profile binding (D-019). */ hermesProfile?: string | null; + /** + * Real 64-hex private key for relay-mode agent authorship. When set, the + * seeded `private_key_nsec` is a real nsec and + * `send_managed_agent_channel_message` can sign kind-9 events as this agent. + * Omitted → mock nsec string (smoke-only). + */ + privateKeyHex?: string; profileReadiness?: | { state: "ready" } | { state: "missing"; profile: string } @@ -2372,7 +2381,9 @@ function buildSeededManagedAgent(seed: MockManagedAgentSeed): MockManagedAgent { respond_to_allowlist: seed.respondToAllowlist ?? [], hermes_profile: seed.hermesProfile ?? null, profile_readiness: seed.profileReadiness ?? null, - private_key_nsec: `nsec1mock${seed.pubkey.slice(0, 20)}`, + private_key_nsec: seed.privateKeyHex + ? nsecEncode(hexToBytes(seed.privateKeyHex)) + : `nsec1mock${seed.pubkey.slice(0, 20)}`, log_lines: [ `buzz-acp starting: relay=${DEFAULT_RELAY_WS_URL} agent_pubkey=${seed.pubkey} parallelism=1`, "profile created; harness not started", @@ -5897,6 +5908,111 @@ async function submitSignedEvent( }); } +/** + * Sign and POST an event as an arbitrary identity (not necessarily the bridge + * user). Used by managed-agent authorship where the event pubkey is the agent. + * Optional `authTagJson` is the NIP-OA `x-auth-tag` header (mirrors Rust + * `submit_event_with_keys` / managed-agent membership delegation). + */ +async function submitSignedEventWithIdentity( + config: E2eConfig | undefined, + identity: TestIdentity, + template: { kind: number; content: string; tags: string[][] }, + authTagJson?: string | null, +): Promise<{ event_id: string; accepted: boolean; message: string }> { + const signed = await signWithIdentity(identity, template); + const headers = new Headers({ + "Content-Type": "application/json", + "X-Pubkey": identity.pubkey, + }); + if (authTagJson?.trim()) { + headers.set("x-auth-tag", authTagJson.trim()); + } + const response = await fetch(`${getRelayHttpUrl(config)}/events`, { + method: "POST", + headers, + body: JSON.stringify(signed), + }); + await assertOk(response); + return response.json() as Promise<{ + event_id: string; + accepted: boolean; + message: string; + }>; +} + +/** Parse a managed agent's stored nsec into a TestIdentity, or null if mock. */ +function managedAgentSigningIdentity( + agent: MockManagedAgent, +): TestIdentity | null { + try { + const decoded = decode(agent.private_key_nsec.trim()); + if (decoded.type !== "nsec") return null; + const privateKey = bytesToHex(decoded.data); + const pubkey = getPublicKey(decoded.data); + if (pubkey.toLowerCase() !== agent.pubkey.toLowerCase()) { + throw new Error( + `managed agent key does not match stored pubkey ${agent.pubkey}`, + ); + } + return { privateKey, pubkey, username: agent.name }; + } catch { + return null; + } +} + +/** + * NIP-OA auth tag JSON for the `x-auth-tag` header, mirroring + * `buzz_sdk::nip_oa::compute_auth_tag` with empty conditions (legacy + * managed-agent path). Returns null when owner == agent (self). + */ +function computeManagedAgentAuthTagJson( + owner: TestIdentity, + agentPubkey: string, +): string | null { + const agent = agentPubkey.trim().toLowerCase(); + if (!agent || owner.pubkey.toLowerCase() === agent) return null; + const conditions = ""; + const preimage = new TextEncoder().encode( + `nostr:agent-auth:${agent}:${conditions}`, + ); + const message = sha256(preimage); + const sig = bytesToHex(schnorr.sign(message, hexToBytes(owner.privateKey))); + return JSON.stringify(["auth", owner.pubkey, conditions, sig]); +} + +const KIND_IA_ARCHIVE_REQUEST = 9035; +const KIND_IA_UNARCHIVE_REQUEST = 9036; + +/** Build the kind:30175 content body mirroring `persona_event_content`. */ +function personaCatalogEventContent(persona: RawPersona): string { + // Field order matches PersonaEventContent / NIP-AP (display_name first). + const content: Record = { + display_name: persona.display_name, + // Always emit system_prompt (including empty) so hash stability matches Rust. + system_prompt: persona.system_prompt, + }; + if (persona.avatar_url) content.avatar_url = persona.avatar_url; + if (persona.runtime) content.runtime = persona.runtime; + if (persona.model) content.model = persona.model; + if (persona.provider) content.provider = persona.provider; + if (persona.name_pool && persona.name_pool.length > 0) { + content.name_pool = persona.name_pool; + } + if (persona.respond_to) content.respond_to = persona.respond_to; + if (persona.respond_to_allowlist && persona.respond_to_allowlist.length > 0) { + content.respond_to_allowlist = persona.respond_to_allowlist; + } + if (persona.parallelism != null) content.parallelism = persona.parallelism; + return JSON.stringify(content); +} + +function personaCatalogTags(persona: RawPersona): string[][] { + const tags: string[][] = [["d", persona.id]]; + if (persona.shared) tags.push(["shared", "true"]); + return tags; +} + /** Build the channel-id → last-message-at map from the returned channel list. */ function buildLastMessages( channels: Array<{ id: string; last_message_at: string | null }>, @@ -8388,11 +8504,40 @@ type MockPersonaPublicationResult = { * Publish a persona's catalog head and report the relay outcome, like * `publish_prepared_persona`. A `queued` outcome must NOT make the event * visible to catalog readers — that is the whole distinction the UI reports. + * + * Relay mode posts kind:30175 via `submitSignedEvent` (D-042) and skips mock + * catalog bookkeeping; the real WebSocket/subscription supplies the echo. */ -function publishMockPersonaHead( +async function publishMockPersonaHead( persona: RawPersona, config: E2eConfig | undefined, -): MockPersonaPublicationResult { +): Promise { + const identity = getIdentity(config); + if (identity) { + try { + await submitSignedEvent(config, { + kind: KIND_PERSONA, + content: personaCatalogEventContent(persona), + tags: personaCatalogTags(persona), + }); + return { + persona: { ...persona }, + publicationStatus: "published", + }; + } catch (error) { + // Mirror Rust: relay rejection / unreachable stays queued for flush. + const message = + error instanceof Error + ? error.message + : "relay unreachable: could not connect to relay"; + return { + persona: { ...persona }, + publicationStatus: "queued", + relayMessage: message, + }; + } + } + const publicationStatus = config?.mock?.personaSharePublicationStatuses?.[ personaSharePublicationCallCount++ @@ -9520,10 +9665,90 @@ async function handleSendManagedAgentChannelMessage( parentEventId?: string | null; additionalMarkers?: string[] | null; }, - _config: E2eConfig | undefined, + config: E2eConfig | undefined, ): Promise { const agent = getMockManagedAgent(args.agentPubkey); + const trimmed = args.content.trim(); + if (!trimmed) { + throw new Error("message content is required"); + } const marker = args.marker?.trim(); + const identity = getIdentity(config); + + if (identity) { + // Relay mode: sign as the managed agent (kind 9), mirroring + // `send_managed_agent_channel_message` in messages.rs. Requires a real + // agent signing key on the mock record (`MockManagedAgentSeed.privateKeyHex`). + const agentIdentity = managedAgentSigningIdentity(agent); + if (!agentIdentity) { + throw new Error( + "send_managed_agent_channel_message in relay mode requires a real agent signing key (seed managedAgents[].privateKeyHex)", + ); + } + + if (marker) { + const filter: Record = { + kinds: [KIND_STREAM_MESSAGE], + "#h": [args.channelId], + limit: 50, + }; + if (args.markerScope !== "channel") { + filter.authors = [agent.pubkey]; + } + const existingEvents = await relayQuery(config, [filter]); + const existing = existingEvents.find((event) => + event.tags.some((tag) => tag[0] === "client" && tag[1] === marker), + ); + if (existing) { + return { + event_id: existing.id, + parent_event_id: args.parentEventId ?? null, + root_event_id: args.parentEventId ?? null, + depth: args.parentEventId ? 1 : 0, + created_at: existing.created_at, + }; + } + } + + const tags = args.parentEventId + ? buildReplyMessageTags( + args.channelId, + agent.pubkey, + args.parentEventId, + args.parentEventId, + args.mentionPubkeys ?? undefined, + ) + : buildTopLevelMessageTags( + args.channelId, + args.mentionPubkeys ?? undefined, + agent.pubkey, + ); + for (const clientMarker of [marker, ...(args.additionalMarkers ?? [])]) { + if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]); + } + + // Membership delegation: when the agent is not the owner, attach the + // owner-signed NIP-OA tag so the relay accepts the kind-9 write. + const authTagJson = computeManagedAgentAuthTagJson(identity, agent.pubkey); + const result = await submitSignedEventWithIdentity( + config, + agentIdentity, + { + kind: KIND_STREAM_MESSAGE, + content: trimmed, + tags, + }, + authTagJson, + ); + return { + event_id: result.event_id, + parent_event_id: args.parentEventId ?? null, + root_event_id: args.parentEventId ?? null, + depth: args.parentEventId ? 1 : 0, + created_at: Math.floor(Date.now() / 1000), + }; + } + if (marker) { const existing = getMockMessageStore(args.channelId).find( (event) => @@ -9559,8 +9784,8 @@ async function handleSendManagedAgentChannelMessage( if (clientMarker?.trim()) tags.push(["client", clientMarker.trim()]); } const event = createMockEvent( - 9, - args.content.trim(), + KIND_STREAM_MESSAGE, + trimmed, tags, agent.pubkey, createdAt, @@ -13460,12 +13685,71 @@ export function maybeInstallE2eTauriMocks() { payload as Parameters[0], activeConfig, ); - case "send_channel_user_input_answer": + case "send_channel_user_input_answer": { + const answerArgs = payload as { + channelId: string; + requestEventId: string; + answers: unknown; + }; + if (getIdentity(activeConfig)) { + // Mirror user_input.rs / build_agent_user_input_answer: look up the + // request, then publish kind 46041 with h + e + p (requesting agent). + if ( + !answerArgs.answers || + typeof answerArgs.answers !== "object" || + Array.isArray(answerArgs.answers) + ) { + throw new Error("answers must be a JSON object"); + } + const requestEventId = answerArgs.requestEventId.toLowerCase(); + const events = await relayQuery(activeConfig, [ + { + ids: [requestEventId], + kinds: [KIND_AGENT_USER_INPUT_REQUESTED], + "#h": [answerArgs.channelId], + limit: 1, + }, + ]); + const request = events.find((event) => event.id === requestEventId); + if (!request) { + throw new Error("user-input request was not found"); + } + if (request.kind !== KIND_AGENT_USER_INPUT_REQUESTED) { + throw new Error("user-input request has the wrong event kind"); + } + const hTags = request.tags.filter((tag) => tag[0] === "h"); + const pTags = request.tags.filter((tag) => tag[0] === "p"); + const ownerPubkey = getRelayIdentity(activeConfig).pubkey; + if (hTags.length !== 1 || hTags[0]?.[1] !== answerArgs.channelId) { + throw new Error( + "user-input request channel relationship is invalid", + ); + } + if ( + pTags.length !== 1 || + pTags[0]?.[1]?.toLowerCase() !== ownerPubkey.toLowerCase() + ) { + throw new Error( + "user-input request is not intended for the current owner", + ); + } + const requestingAgent = request.pubkey; + return submitSignedEvent(activeConfig, { + kind: KIND_AGENT_USER_INPUT_ANSWER, + content: JSON.stringify(answerArgs.answers), + tags: [ + ["h", answerArgs.channelId], + ["e", requestEventId], + ["p", requestingAgent], + ], + }); + } return { event_id: `mock-user-input-answer-${Date.now()}`, accepted: true, message: "accepted", }; + } case "has_managed_agent_channel_message_marker": { const args = payload as { channelId: string; @@ -13796,10 +14080,64 @@ export function maybeInstallE2eTauriMocks() { } return activeConfig?.mock?.relaySelf ?? null; case "archive_identity": - case "unarchive_identity": - // The spec only verifies UI state, not the submitted request shape; - // returning null mirrors the Rust submit_event success path. + case "unarchive_identity": { + // Rust: identity_archive.rs → kind 9035 / 9036 via build_*_identity_request. + // Self path attaches no auth tag; owner-of-agent attaches the verified + // NIP-OA auth tag from the target's live kind:0 when is_me. + if (getIdentity(activeConfig)) { + const isArchive = command === "archive_identity"; + const req = ( + payload as { + req: { + targetPubkey: string; + content?: string; + reason?: string | null; + replacedBy?: string | null; + }; + } + ).req; + const target = req.targetPubkey.trim().toLowerCase(); + const content = req.content ?? ""; + const tags: string[][] = [["-"], ["p", target]]; + if (req.reason?.trim()) { + tags.push(["reason", req.reason.trim()]); + } + if (isArchive && req.replacedBy?.trim()) { + const replacedBy = req.replacedBy.trim().toLowerCase(); + if (replacedBy === target) { + throw new Error("replaced-by must differ from the target"); + } + tags.push(["replaced-by", replacedBy]); + } + // Owner path: attach auth tag when live kind:0 proves we own target. + const me = getRelayIdentity(activeConfig).pubkey.toLowerCase(); + if (me !== target) { + const profiles = await relayQuery(activeConfig, [ + { kinds: [0], authors: [target], limit: 1 }, + ]); + const kind0 = profiles[0]; + const auth = kind0?.tags.find( + (tag) => + tag[0] === "auth" && + tag.length === 4 && + tag[1]?.toLowerCase() === me, + ); + if (auth && auth[1] && auth[3]) { + tags.push(["auth", auth[1], auth[2] ?? "", auth[3]]); + } + } + return submitSignedEvent(activeConfig, { + kind: isArchive + ? KIND_IA_ARCHIVE_REQUEST + : KIND_IA_UNARCHIVE_REQUEST, + content, + tags, + }); + } + // The mock path only verifies UI state, not the submitted request shape; + // returning null mirrors a swallowed submit success for smoke specs. return null; + } case "set_canvas": { const canvasArgs = payload as { channelId: string; content: string }; if (getIdentity(activeConfig)) { diff --git a/desktop/tests/e2e/bridge-relay-mutations.spec.ts b/desktop/tests/e2e/bridge-relay-mutations.spec.ts new file mode 100644 index 00000000000..e9af9b13e34 --- /dev/null +++ b/desktop/tests/e2e/bridge-relay-mutations.spec.ts @@ -0,0 +1,515 @@ +import { expect, test } from "@playwright/test"; +import { schnorr } from "@noble/curves/secp256k1.js"; +import { sha256 } from "@noble/hashes/sha2.js"; +import { bytesToHex, hexToBytes } from "@noble/hashes/utils.js"; +import { finalizeEvent } from "nostr-tools/pure"; + +import { installBridge, TEST_IDENTITIES } from "../helpers/bridge"; +import { assertRelaySeeded } from "../helpers/seed"; + +const RELAY_HTTP_URL = + process.env.BUZZ_E2E_RELAY_URL ?? "http://localhost:3000"; +/** Relay-seeded #general channel id (uuid5 of buzz.channel.general). */ +const GENERAL_CHANNEL_ID = "9f28288a-d724-587a-9709-92dc7f967110"; + +type RelayEvent = { + id: string; + kind: number; + pubkey: string; + content: string; + tags: string[][]; + created_at: number; +}; + +async function publishEvent( + identity: { privateKey: string; pubkey: string }, + template: { + kind: number; + content: string; + tags: string[][]; + created_at?: number; + }, + extraHeaders: Record = {}, +): Promise { + const event = finalizeEvent( + { + kind: template.kind, + content: template.content, + tags: template.tags, + created_at: template.created_at ?? Math.floor(Date.now() / 1000), + }, + hexToBytes(identity.privateKey), + ); + const response = await fetch(`${RELAY_HTTP_URL}/events`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Pubkey": event.pubkey, + ...extraHeaders, + }, + body: JSON.stringify(event), + }); + if (!response.ok) { + throw new Error( + `POST /events failed (${response.status}): ${await response.text()}`, + ); + } + return event as RelayEvent; +} + +async function queryRelay( + filters: Array>, + asPubkey = TEST_IDENTITIES.tyler.pubkey, +): Promise { + const response = await fetch(`${RELAY_HTTP_URL}/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Pubkey": asPubkey, + }, + body: JSON.stringify(filters), + }); + if (!response.ok) { + throw new Error( + `POST /query failed (${response.status}): ${await response.text()}`, + ); + } + return (await response.json()) as RelayEvent[]; +} + +/** NIP-OA auth tag JSON: tyler owns `agentPubkey`. */ +function computeOwnerAuthTagJson(agentPubkey: string): string { + const conditions = ""; + const preimage = new TextEncoder().encode( + `nostr:agent-auth:${agentPubkey.toLowerCase()}:${conditions}`, + ); + const message = sha256(preimage); + const sig = bytesToHex( + schnorr.sign(message, hexToBytes(TEST_IDENTITIES.tyler.privateKey)), + ); + return JSON.stringify([ + "auth", + TEST_IDENTITIES.tyler.pubkey, + conditions, + sig, + ]); +} + +async function invokeBridgeCommand( + page: import("@playwright/test").Page, + command: string, + payload?: Record, +): Promise { + await page.waitForFunction( + () => { + const w = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown; + __TAURI_INTERNALS__?: { invoke?: unknown }; + }; + return ( + typeof w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ === "function" || + typeof w.__TAURI_INTERNALS__?.invoke === "function" + ); + }, + null, + { timeout: 10_000 }, + ); + return page.evaluate( + async ({ command: cmd, payload: pl }) => { + const w = window as Window & { + __BUZZ_E2E_INVOKE_MOCK_COMMAND__?: ( + command: string, + payload?: Record, + ) => Promise; + __TAURI_INTERNALS__?: { + invoke?: ( + command: string, + payload?: Record, + ) => Promise; + }; + }; + const invoke = + w.__BUZZ_E2E_INVOKE_MOCK_COMMAND__ ?? w.__TAURI_INTERNALS__?.invoke; + if (!invoke) throw new Error("Mock invoke bridge is unavailable."); + return invoke(cmd, pl); + }, + { command, payload }, + ); +} + +test.beforeAll(async () => { + await assertRelaySeeded(); +}); + +test("archive_identity and unarchive_identity publish real kind 9035/9036", async ({ + page, +}) => { + await installBridge(page, { + mode: "relay", + user: "tyler", + relayHttpUrl: RELAY_HTTP_URL, + relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"), + }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible({ + timeout: 15_000, + }); + + const target = TEST_IDENTITIES.tyler.pubkey; + const reason = `bridge-relay-self-${Date.now()}`; + + const archiveResult = (await invokeBridgeCommand(page, "archive_identity", { + req: { + targetPubkey: target, + content: "self archive via bridge", + reason, + }, + })) as { event_id?: string; accepted?: boolean }; + + expect(archiveResult?.accepted ?? true).toBeTruthy(); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [9035], + authors: [target], + "#p": [target], + limit: 20, + }, + ]) + ).find( + (event) => + event.kind === 9035 && + event.pubkey === target && + event.tags.some((tag) => tag[0] === "-") && + event.tags.some( + (tag) => + tag[0] === "p" && + tag[1]?.toLowerCase() === target.toLowerCase(), + ) && + event.tags.some((tag) => tag[0] === "reason" && tag[1] === reason), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); + + const unarchiveResult = (await invokeBridgeCommand( + page, + "unarchive_identity", + { + req: { + targetPubkey: target, + content: "self unarchive via bridge", + reason: `un-${reason}`, + }, + }, + )) as { event_id?: string; accepted?: boolean }; + expect(unarchiveResult?.accepted ?? true).toBeTruthy(); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [9036], + authors: [target], + "#p": [target], + limit: 20, + }, + ]) + ).find( + (event) => + event.kind === 9036 && + event.pubkey === target && + event.tags.some((tag) => tag[0] === "-") && + event.tags.some( + (tag) => + tag[0] === "p" && + tag[1]?.toLowerCase() === target.toLowerCase(), + ), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); +}); + +test("update_persona_and_publish posts a real kind 30175 catalog head", async ({ + page, +}) => { + const personaId = `bridge-relay-persona-${Date.now().toString(36)}`; + const displayName = `Relay Persona ${Date.now()}`; + + await installBridge(page, { + mode: "relay", + user: "tyler", + relayHttpUrl: RELAY_HTTP_URL, + relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"), + mock: { + personas: [ + { + id: personaId, + displayName: "Before publish", + systemPrompt: "You are a test persona.", + shared: true, + isActive: true, + }, + ], + }, + }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible({ + timeout: 15_000, + }); + + const result = (await invokeBridgeCommand( + page, + "update_persona_and_publish", + { + input: { + id: personaId, + displayName, + systemPrompt: "You are a relay-published test persona.", + }, + }, + )) as { + publicationStatus?: string; + persona?: { display_name?: string; id?: string }; + }; + + expect(result.publicationStatus).toBe("published"); + expect(result.persona?.display_name).toBe(displayName); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [30175], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#d": [personaId], + limit: 5, + }, + ]) + ).find((event) => { + if (event.kind !== 30175) return false; + if (event.pubkey !== TEST_IDENTITIES.tyler.pubkey) return false; + if ( + !event.tags.some((tag) => tag[0] === "d" && tag[1] === personaId) + ) { + return false; + } + try { + const body = JSON.parse(event.content) as { + display_name?: string; + }; + return body.display_name === displayName; + } catch { + return false; + } + }), + { timeout: 15_000 }, + ) + .toBeTruthy(); +}); + +test("send_managed_agent_channel_message publishes a real kind-9 as the agent", async ({ + page, +}) => { + const content = `managed-agent relay message ${Date.now()}`; + const marker = `bridge-managed-${Date.now()}`; + + await installBridge(page, { + mode: "relay", + user: "tyler", + relayHttpUrl: RELAY_HTTP_URL, + relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"), + mock: { + managedAgents: [ + { + pubkey: TEST_IDENTITIES.alice.pubkey, + name: "Alice Agent", + privateKeyHex: TEST_IDENTITIES.alice.privateKey, + status: "running", + channelIds: [GENERAL_CHANNEL_ID], + }, + ], + }, + }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible({ + timeout: 15_000, + }); + + const result = (await invokeBridgeCommand( + page, + "send_managed_agent_channel_message", + { + agentPubkey: TEST_IDENTITIES.alice.pubkey, + channelId: GENERAL_CHANNEL_ID, + content, + marker, + }, + )) as { event_id?: string }; + + expect(result.event_id).toMatch(/^[0-9a-f]{64}$/); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [9], + authors: [TEST_IDENTITIES.alice.pubkey], + "#h": [GENERAL_CHANNEL_ID], + limit: 20, + }, + ]) + ).find( + (event) => + event.id === result.event_id && + event.kind === 9 && + event.pubkey === TEST_IDENTITIES.alice.pubkey && + event.content === content && + event.tags.some( + (tag) => tag[0] === "h" && tag[1] === GENERAL_CHANNEL_ID, + ) && + event.tags.some((tag) => tag[0] === "client" && tag[1] === marker), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); +}); + +test("send_channel_user_input_answer publishes a real kind 46041", async ({ + page, +}) => { + // Seed path mirrors production: materialize alice as tyler's agent via NIP-OA, + // publish a parent that @-targets alice, then a 46040 request alice→tyler. + const authTagJson = computeOwnerAuthTagJson(TEST_IDENTITIES.alice.pubkey); + + // Materialize agent_owner via x-auth-tag on any accepted alice-authored event. + const authTag = JSON.parse(authTagJson) as [string, string, string, string]; + await publishEvent( + TEST_IDENTITIES.alice, + { + kind: 0, + content: JSON.stringify({ + name: "Alice Agent", + about: "bridge-relay user-input agent", + }), + tags: [authTag], + }, + { "x-auth-tag": authTagJson }, + ); + + const parent = await publishEvent(TEST_IDENTITIES.tyler, { + kind: 9, + content: `trigger for user-input ${Date.now()}`, + tags: [ + ["h", GENERAL_CHANNEL_ID], + ["p", TEST_IDENTITIES.alice.pubkey], + ], + }); + + const requestContent = JSON.stringify({ + request_id: `req-${Date.now()}`, + session_id: "session-bridge-relay", + turn_id: "turn-1", + channel_id: GENERAL_CHANNEL_ID, + tool_call_id: null, + engine: "codex", + message: "Pick one", + questions: [ + { + id: "q0", + header: "Choice", + question: "Which path?", + options: [ + { + value: "a", + label: "A", + description: "Option A", + }, + ], + }, + ], + }); + + const request = await publishEvent( + TEST_IDENTITIES.alice, + { + kind: 46040, + content: requestContent, + tags: [ + ["h", GENERAL_CHANNEL_ID], + ["p", TEST_IDENTITIES.tyler.pubkey], + ["e", parent.id, "", "reply"], + ], + }, + { "x-auth-tag": authTagJson }, + ); + + await installBridge(page, { + mode: "relay", + user: "tyler", + relayHttpUrl: RELAY_HTTP_URL, + relayWsUrl: RELAY_HTTP_URL.replace(/^http/, "ws"), + }); + await page.goto("/"); + await expect(page.getByTestId("app-sidebar")).toBeVisible({ + timeout: 15_000, + }); + + const answers = { q0: "a" }; + const result = (await invokeBridgeCommand( + page, + "send_channel_user_input_answer", + { + channelId: GENERAL_CHANNEL_ID, + requestEventId: request.id, + answers, + }, + )) as { event_id?: string; accepted?: boolean }; + + expect(result.accepted).toBe(true); + expect(result.event_id).toMatch(/^[0-9a-f]{64}$/); + + await expect + .poll( + async () => + ( + await queryRelay([ + { + kinds: [46041], + authors: [TEST_IDENTITIES.tyler.pubkey], + "#e": [request.id], + limit: 10, + }, + ]) + ).find( + (event) => + event.id === result.event_id && + event.kind === 46041 && + event.pubkey === TEST_IDENTITIES.tyler.pubkey && + event.content === JSON.stringify(answers) && + event.tags.some( + (tag) => tag[0] === "h" && tag[1] === GENERAL_CHANNEL_ID, + ) && + event.tags.some((tag) => tag[0] === "e" && tag[1] === request.id) && + event.tags.some( + (tag) => + tag[0] === "p" && + tag[1]?.toLowerCase() === + TEST_IDENTITIES.alice.pubkey.toLowerCase(), + ), + ), + { timeout: 15_000 }, + ) + .toBeTruthy(); +}); diff --git a/docs/crew/STATE.md b/docs/crew/STATE.md index d60dcf37d88..c56adf27e8b 100644 --- a/docs/crew/STATE.md +++ b/docs/crew/STATE.md @@ -154,7 +154,9 @@ Out of scope for this slice: - Phase 09 live probe results are recorded in [`verification/0010-evidence-on-thread-log-probes.md`](verification/0010-evidence-on-thread-log-probes.md). - Verification 0011 records the closed headless click-to-real-relay reaction - path and the remaining relay-mutation audit. + path (#133) and the #144 remaining-mutation pass (user-input answer, persona + publish, identity archive, managed-agent message) plus Rust confirmation of + workflow (still mock-only on the bridge) vs local-archive (confirmed local). - The desktop unit suite passes with 5045 tests passing, one skipped, and zero failures. - `buzz-acp` uses the process cwd for ordinary sessions and one validated, diff --git a/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md b/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md index 3bdce80eab5..cf5a828f7af 100644 --- a/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md +++ b/docs/crew/verification/0011-e2e-bridge-relay-mutation-audit.md @@ -1,19 +1,24 @@ # Verification 0011 — E2E bridge relay mutation audit -- **Date:** 2026-08-11 +- **Date:** 2026-08-12 (updated for #144; original #133 2026-08-11) - **Issue:** #133; follow-up #144 -- **Branch / commit:** `devin/issue-133-relay-add-reaction` @ `b4f29025c` +- **Branch / commit:** `feat/144-bridge-relay-mutations` (see PR for SHA) - **Plan phase:** relay-backed desktop mutation coverage ## Boundary exercised The desktop E2E bridge now sends real Nostr mutations when `getIdentity(config)` returns a relay identity. Relay branches call -`submitSignedEvent`, and do not update the mock message/feed stores or emit a +`submitSignedEvent` (or `submitSignedEventWithIdentity` for managed-agent +authorship), and do not update the mock message/feed stores or emit a mock live event. The real WebSocket subscription supplies the relay echo. Mock mode keeps the existing in-memory behavior. -The single-run evidence path is: +Decision **D-042**: mutating bridge commands with a real Nostr event behind +them publish through `submitSignedEvent` in relay mode and skip mock-store +bookkeeping. + +The single-run evidence path (#133) is: 1. Publish an evidence-tagged kind-9 message as Alice with `POST /events`. 2. Open the message in the headless desktop app as Tyler. @@ -27,9 +32,14 @@ The evidence message and both reaction events use the real local relay. The agent ownership metadata remains a bridge-config profile injection; see [Limits](#limits). +Issue **#144** extended the same boundary to the remaining mock-only +mutating commands listed under [(a) Already relay-aware](#a-already-relay-aware) +and confirmed workflow / local-archive classification against the Rust +commands (see [Rust confirmation (#144)](#rust-confirmation-144)). + ## CI coverage -The relay-backed desktop spec is covered by the advisory +The relay-backed desktop specs are covered by the advisory `Desktop E2E Integration` job in the active `.github/workflows/nuncio-crew-ci.yml` workflow (`playwright test --project=integration`, two shards, real Postgres/Redis/MinIO + built @@ -60,11 +70,14 @@ timeout). The Crew lane mirrors that shape (30-minute timeout to absorb the per-shard relay build). `Project Relay` still provisions its own stack inside `scripts/run-nuncio-crew-project-relay-ci.sh` and does not run Playwright; the integration job duplicates services by design so it never becomes a hard Gate -dependency. Of the configured integration specs, -`evidence-reactions-relay.spec.ts` remains the only Crew-specific one; the -rest are inherited Buzz coverage. +dependency. Crew-specific relay-mutation specs: + +- `evidence-reactions-relay.spec.ts` (#133) +- `bridge-relay-mutations.spec.ts` (#144) — archive/unarchive identity, + update_persona_and_publish, send_managed_agent_channel_message, + send_channel_user_input_answer -### How to run this spec locally +### How to run these specs locally ```bash . ./bin/activate-hermit @@ -74,7 +87,7 @@ just relay bash scripts/setup-desktop-test-data.sh pnpm --filter buzz build:e2e cd desktop -pnpm exec playwright test evidence-reactions-relay.spec.ts --project=integration +pnpm exec playwright test evidence-reactions-relay.spec.ts bridge-relay-mutations.spec.ts --project=integration ``` ## Local relay and test data @@ -105,7 +118,7 @@ The desktop seed was initialized with: ## Relay-backed evidence spec -The new spec is +The #133 spec is `desktop/tests/e2e/evidence-reactions-relay.spec.ts`, registered in the integration project. It was run with: @@ -126,6 +139,20 @@ Running 1 test using 1 worker The spec asserts the raw relay events directly: kind `7`, Tyler's pubkey, exact `✅`/`❌` content, and an `e` tag targeting the published evidence event. +## Relay-backed bridge mutation specs (#144) + +The #144 spec is +`desktop/tests/e2e/bridge-relay-mutations.spec.ts` (integration project). +Each test invokes the bridge command path under `mode: "relay"` and polls +`POST /query` for the real event: + +| Spec | Command | Kind / author | Tags asserted | +|---|---|---|---| +| archive/unarchive | `archive_identity` / `unarchive_identity` | 9035 / 9036, self | `-`, `p`, optional `reason` | +| persona catalog | `update_persona_and_publish` | 30175, tyler | `d`, content `display_name` | +| managed agent msg | `send_managed_agent_channel_message` | 9, agent (alice) | `h`, `client` marker; agent signing + NIP-OA `x-auth-tag` | +| user-input answer | `send_channel_user_input_answer` | 46041, tyler | `h`, `e`→request, `p`→requesting agent | + ## Verification commands The E2E bundle was rebuilt after the bridge style edits: @@ -134,234 +161,84 @@ The E2E bundle was rebuilt after the bridge style edits: CI=true . ./bin/activate-hermit && CI=true pnpm --filter buzz build:e2e ``` -The relevant successful output was: - -```text -Scope: all 4 workspace projects -Lockfile is up to date, resolution step is skipped -Done in 1.1s using pnpm v11.4.0 -$ tsc && vite build --mode e2e -✓ 4864 modules transformed. -✓ built in 1.76s -``` - -Desktop checks passed: +Desktop checks: ```bash . ./bin/activate-hermit && pnpm --filter buzz check -# Checked 2200 files in 1421ms. No fixes applied. -# $ node ./scripts/check-file-sizes.mjs -# $ node ./scripts/check-px-text.mjs -# $ node ./scripts/check-pubkey-truncation.mjs - . ./bin/activate-hermit && pnpm --filter buzz typecheck -# $ tsc --noEmit -``` - -The requested smoke command ran 29 tests: - -```bash -cd desktop -pnpm exec playwright test --project=smoke \ - evidence-reactions.spec.ts evidence-cards.spec.ts reaction-order.spec.ts \ - reaction-names.spec.ts inbox-reactions.spec.ts custom-emoji.spec.ts \ - empty-edit-delete.spec.ts -``` - -Its result was: - -```text - ✓ 29 [smoke] › tests/e2e/reaction-order.spec.ts:126:1 › a later emoji that accrues more reactors stays to the right of an earlier emoji (4.1s) - 1) [smoke] › tests/e2e/inbox-reactions.spec.ts:36:1 › inbox reaction on a thread-reply mention persists after refetch - ... - 28 passed (1.1m) -``` - -The Inbox failure is pre-existing. It was baselined from a separate clean -worktree at `origin/devin/1786360062-evidence-thread-log` (`df2a9995e`): - -```bash -git worktree add /tmp/i133-base origin/devin/1786360062-evidence-thread-log -cd /tmp/i133-base -CI=true . ./bin/activate-hermit && CI=true pnpm --filter buzz build:e2e -cd desktop -pnpm exec playwright test inbox-reactions.spec.ts --project=smoke -``` - -The clean-base run produced the same failure: - -```text - ✘ 1 [smoke] › tests/e2e/inbox-reactions.spec.ts:36:1 › inbox reaction on a thread-reply mention persists after refetch (6.1s) - - Error: expect(locator).toBeVisible() failed - Locator: getByTestId('home-inbox-selected-message').getByLabel('Toggle ❤️ reaction') - Expected: visible - Error: element(s) not found -``` - -The branch failure was therefore not caused by the relay mutation changes. - -### Smoke shard-1 baseline - -Clean-main run -[31362178966/job/93373095535](https://github.com/Nuncio-hq/crew/actions/runs/31362178966/job/93373095535) -reported these six failures: - -```text -6 failed -[smoke] › tests/e2e/channel-activity-popover.spec.ts:274:3 › channel activity hover preview › shows unread channel activity and working agents, then opens the selected thread -[smoke] › tests/e2e/channel-activity-popover.spec.ts:368:3 › channel activity hover preview › removes the dot and preview after the final activity is read -[smoke] › tests/e2e/channel-activity-popover.spec.ts:459:3 › channel activity hover preview › supports row actions and opens an agent's scoped activity -[smoke] › tests/e2e/channel-activity-popover.spec.ts:753:3 › channel activity hover preview › surfaces future replies after the user reacts to a thread root -[smoke] › tests/e2e/channel-agent-presence.spec.ts:100:3 › channel header agent presence › shows needs-you for a 46040 request and opens its real thread -[smoke] › tests/e2e/channels.spec.ts:500:1 › channel question card accepts an answer ``` -The PR shard-1 log -[31448955605/job/93649138295](https://github.com/Nuncio-hq/crew/actions/runs/31448955605/job/93649138295) -also reported `channels.spec.ts:1951` and `channels.spec.ts:2108`, but -neither appears in the clean-main failure list above. Both reproduce -identically on the PR #128 parent commit `df2a9995e`, so they are inherited -from that base branch rather than introduced by this change: - -```text -Error: expect(locator).toHaveCount failed -Locator: getByTestId('message-timeline-day-group') -Expected: 2 -Received: 0 -Timeout: 5000ms -``` - -```text -Error: expect(locator).toContainText failed -Locator: getByTestId('agent-session-thread-panel') -Expected substring: "No ACP activity yet" -Received string: "AaliceActivity · #agents·No updates yet" -Timeout: 5000ms -``` - -### Smoke shard-3 baseline - -The three additional failures from -[PR #146 shard 3](https://github.com/Nuncio-hq/crew/actions/runs/31451647392/job/93657147023) -also reproduce on the PR #128 parent (`df2a9995e`) and on clean -`origin/main` (`35af74019`). They were already present in the earlier PR -shard-3 run -[31448955605/job/93649138303](https://github.com/Nuncio-hq/crew/actions/runs/31448955605/job/93649138303), -so they are upstream-side pre-existing failures, not regressions from the -relay bridge changes: - -| Spec | Branch `413afe3fd` | PR #128 parent `df2a9995e` | `origin/main` `35af74019` | -|---|---:|---:|---:| -| `inbox-edit.spec.ts:175` | fail | fail | fail | -| `inbox-edit.spec.ts:325` | fail | fail | fail | -| `messaging.spec.ts:1819` | fail | fail | fail | - -The edit assertions consistently showed: - -```text -- Attachment reply after editing. -+ 👍❤️😂🎉YYouAug 11, 2026, 2:44 AMAttachment reply before editing. -+ inbox-edit-proof.pdf -``` - -```text -Expected substring: "My Inbox message after editing." -Received string: "👍❤️😂🎉Nnpub1mock...Aug 11, 2026, 2:44 AMMy Inbox message before editing." -``` - -The reaction-shaped messaging assertion consistently showed: - -```text -Locator: ...getByLabel('Toggle 👍 reaction') -Expected: visible -Error: element(s) not found -``` - -### Smoke shard-2 baseline - -PR #146 shard 2 -[31493426658/job/93785356715](https://github.com/Nuncio-hq/crew/actions/runs/31493426658/job/93785356715) -reported one failure: - -```text -[smoke] › tests/e2e/evidence-reactions.spec.ts:46:1 › owner Accept and Reject round-trip as reactions on the evidence card -``` - -The error is a strict-mode violation because the rejected indicator appears -in both the message timeline and the opened thread head after Reject opens -the reply composer: - -```text -Locator: getByTestId('evidence-card-test-run').getByTestId('evidence-reaction-rejected') -Error: strict mode violation: ... resolved to 2 elements: - 1) ... getByTestId('message-timeline').getByTestId('evidence-reaction-rejected') - 2) ... getByTestId('message-thread-head').getByTestId('evidence-reaction-rejected') -``` - -The same three-test file run against clean `origin/main` -(`8b4e8aad1`) reproduced the failure exactly. It is not caused by the -relay-mutation bridge changes; `evidence-reactions.spec.ts` is an inherited -PR #128 test whose `card` selector does not narrow to the timeline card when -the reply composer opens the thread head. - ## Mock-boundary audit “Relay-aware” means the bridge has a relay branch that publishes the real event. “Mock-only trap” means relay mode can return a successful-looking result without reaching the relay. “Local-only” means the bridge-side mutation is -local or an OS/plugin shim. The class-(c) rows marked unconfirmed were inferred -from the bridge side and still need Rust-side confirmation; issue #144 tracks -that confirmation. +local or an OS/plugin shim (and Rust does not publish a Nostr mutation for +that command — confirmed where noted). ### (a) Already relay-aware | Command(s) | Evidence | |---|---| -| `update_profile` | `handleUpdateProfile` `desktop/src/testing/e2eBridge.ts:5940-6007`; relay kind-0 submission at `:6006`. | -| `create_channel` | `:6341-6405`; relay kind-9004 submission at `:6404`. | -| `open_dm` | `:6436-6507`; signed relay submission at `:6499`. | -| `hide_dm` | `:6534-6556`; relay submission at `:6552`. | -| `update_channel` | `:6645-6700`; kind-9002 submission at `:6696`. | -| `set_channel_topic`, `set_channel_purpose` | `:6730-6780`; relay submissions at `:6749` and `:6778`. | -| `archive_channel`, `unarchive_channel` | `:6842-6880`; relay submissions at `:6854` and `:6876`. | -| `delete_channel` | `:6886-6907`; relay kind-9008 submission at `:6904`. | -| `add_channel_members` | `:6911-7031`; relay kind-9000 submission at `:7027`. | -| `remove_channel_member` | `:7036-7059`; relay kind-9001 submission at `:7055`. | -| `join_channel` | `:7065-7095`; relay kind-9021 submission at `:7091`. | -| `leave_channel` | `:7098-7121`; relay kind-9022 submission at `:7117`. | -| `send_channel_message` | `:9160-9342`; relay submission at `:9336`, without mock bookkeeping. | -| `edit_message` | `:9459-9505`; relay kind-40003 submission at `:9503`. | +| `update_profile` | `handleUpdateProfile`; relay kind-0 submission. | +| `create_channel` | relay kind-9004 submission. | +| `open_dm` | signed relay submission. | +| `hide_dm` | relay submission. | +| `update_channel` | kind-9002 submission. | +| `set_channel_topic`, `set_channel_purpose` | relay submissions. | +| `archive_channel`, `unarchive_channel` | relay submissions. | +| `delete_channel` | relay kind-9008 submission. | +| `add_channel_members` | relay kind-9000 submission. | +| `remove_channel_member` | relay kind-9001 submission. | +| `join_channel` | relay kind-9021 submission. | +| `leave_channel` | relay kind-9022 submission. | +| `send_channel_message` | relay submission without mock bookkeeping. | +| `edit_message` | relay kind-40003 submission. | +| `delete_message` | **Fixed in #133.** Relay publishes the Rust-compatible kind-5 `h`+`e` event. | +| `add_reaction` | **Fixed in #133.** Relay publishes exact kind-7 content/tags. | +| `remove_reaction` | **Fixed in #133.** Relay queries the caller's own kind-7 and deletes it with kind 5. | +| `set_canvas` | **Fixed in #133.** Relay publishes kind 40100 with `h`. | +| `send_channel_user_input_answer` | **Fixed in #144.** Relay path looks up the 46040 request, then publishes kind **46041** with `h` + `e` + `p` (requesting agent). Mirrors `desktop/src-tauri/src/commands/user_input.rs`. Spec: `bridge-relay-mutations.spec.ts`. | +| `update_persona_and_publish` | **Fixed in #144.** `publishMockPersonaHead` posts kind **30175** via `submitSignedEvent` (content/tags mirror `persona_event_content` / NIP-AP); mock catalog bookkeeping skipped on success. Also covers `set_persona_shared` through the same helper. Spec: `bridge-relay-mutations.spec.ts`. | +| `archive_identity`, `unarchive_identity` | **Fixed in #144.** Relay posts kind **9035** / **9036** with `-` + `p` (+ optional `reason` / `replaced-by`); owner path attaches live kind:0 NIP-OA `auth` tag when present. Mirrors `identity_archive.rs` / `events::build_*_identity_request`. Spec: `bridge-relay-mutations.spec.ts`. | +| `send_managed_agent_channel_message` | **Fixed in #144.** Relay path signs kind **9** as the managed agent (`MockManagedAgentSeed.privateKeyHex` → real nsec), attaches client markers, and sends NIP-OA `x-auth-tag` from the owner identity (mirrors `managed_agent_submission_auth_tag`). Without a real agent key the command errors visibly rather than silently mocking. Spec: `bridge-relay-mutations.spec.ts`. | ### (b) Mock-only traps -| Command(s) | Status and evidence | -|---|---| -| `delete_message` | **Fixed in this change.** Former mock-only handler `:9425-9455`; relay now publishes the Rust-compatible kind-5 `h`+`e` event. | -| `add_reaction` | **Fixed in this change.** Former mock-only handler `:9528-9576`; relay now publishes exact kind-7 content/tags. | -| `remove_reaction` | **Fixed in this change.** Former mock-only handler `:9579-9614`; relay now queries the caller's own kind-7 and deletes it with kind 5. | -| `set_canvas` | **Fixed in this change.** Dispatcher `:13363-13364` formerly returned a synthetic ID; relay now publishes kind 40100 with `h`. | -| `send_managed_agent_channel_message` | **Not fixed; #144.** `:9351-9420` uses mock agent/store state and emits a mock kind-9. The real event needs an agent identity/signing decision. | -| `send_channel_user_input_answer` | **Not fixed; #144.** Dispatcher `:13023-13028` returns a synthetic accepted response; the Rust command has a durable relay event. | -| `update_persona_and_publish` | **Not fixed; #144.** Dispatcher `:12278-12282` reaches mock persona catalog state; the Rust operation publishes persona catalog kind 30175. | -| `archive_identity`, `unarchive_identity` | **Not fixed; #144.** Dispatcher `:13358-13360` is a UI-only stub; the Rust commands publish NIP-IA archival events. | - -### (c) Legitimately local-only or pending confirmation - -| Command(s) | Bridge-side classification | +None remaining from the #133/#144 lists. Commands that still stop at the mock +boundary despite a real Rust Nostr mutation are recorded under +[(c)](#c-legitimately-local-only-or-confirmed--misclassified) as +**misclassified / follow-up** (workflows). + +### (c) Legitimately local-only or confirmed / misclassified + +| Command(s) | Classification after Rust confirmation (#144) | |---|---| -| `create_persona`, `update_persona`, `delete_persona`, `set_persona_active`, `set_persona_shared` | Local persona/catalog persistence; `update_persona` queues local pending state (`:8090-8096`). Rust-side confirmation remains tracked by #144. | +| `create_persona`, `update_persona`, `delete_persona`, `set_persona_active` | Local persona catalog persistence; `update_persona` queues local pending state. Publishing is the separate `update_persona_and_publish` / `set_persona_shared` path (now relay-aware). | | `create_channel_template` | E2E-only local template fixture state. | -| `create_team`, `update_team`, `delete_team`, `install_team_from_directory`, `sync_team_directory`, `confirm_*_snapshot_import` | Local persona/team files and SQLite/import workflows; Rust-side confirmation remains tracked by #144. | -| Managed-agent create/update/start/stop/delete/runtime-pair commands | Local records, process lifecycle, and runtime bookkeeping; Rust-side confirmation remains tracked by #144. | +| `create_team`, `update_team`, `delete_team`, `install_team_from_directory`, `sync_team_directory`, `confirm_*_snapshot_import` | Local persona/team files and SQLite/import workflows. | +| Managed-agent create/update/start/stop/delete/runtime-pair commands | Local records, process lifecycle, and runtime bookkeeping (agent **message** authorship is relay-aware above). | | `create_hermes_profile`, `delete_hermes_profile` | Local Hermes profile-manager state. | | `save_custom_harness`, `delete_custom_harness`, `connect_acp_runtime`, `install_acp_runtime` | Local ACP configuration/process installation. | -| `plugin:process|restart`, updater, opener, window/resource/plugin commands | OS/plugin/process shims, not relay mutations. | +| `plugin:process\|restart`, updater, opener, window/resource/plugin commands | OS/plugin/process shims, not relay mutations. | | Pairing and identity-recovery UI commands | Native pairing flow; relay interaction is outside this mocked command state. | -| `create_workflow`, `update_workflow`, `delete_workflow`, `trigger_workflow` | Bridge-local workflow/run records. Whether durable workflow state has a Rust-side relay mutation is unconfirmed; #144 tracks it. | -| `create_save_subscription`, `delete_save_subscription`, `merge_save_subscription_kinds`, `remove_save_subscription_kind`, `archive_events` | Bridge-local save-subscription/archive state; Rust-side confirmation is unconfirmed and tracked by #144. | +| `create_workflow`, `update_workflow`, `delete_workflow`, `trigger_workflow` | **Misclassified on the bridge side.** Rust (`commands/workflows.rs`) **does** publish via `submit_event`: kind **30620** definition (`d`+`h`), kind **5** delete targeting `a=30620:owner:id`, kind **46020** trigger (`d`). Bridge handlers remain mock-local. Follow-up candidate (not in #144 DoD command list). | +| `create_save_subscription`, `delete_save_subscription`, `merge_save_subscription_kinds`, `remove_save_subscription_kind`, `archive_events` | **Confirmed local-only against Rust.** `archive/mod.rs`: `create_save_subscription` probes access then upserts SQLite; `archive_events` queries the relay then persists to the local archive DB — neither command publishes a mutation event. Bridge mock state matches that boundary. | | Sleep prevention, clipboard/download/save, and media picker/upload shims | OS/filesystem/native media shims; separate message commands publish relay events. | +## Rust confirmation (#144) + +Checked against: + +| Area | Rust source | Result | +|---|---|---| +| User-input answer | `desktop/src-tauri/src/commands/user_input.rs` | Durable kind 46041 via `build_agent_user_input_answer` + `submit_event`. Bridge now mirrors. | +| Persona publish | `desktop/src-tauri/src/commands/personas/sharing.rs` | Kind 30175 via `submit_signed_event_at_with_keys`. Bridge now mirrors. | +| Identity archive | `desktop/src-tauri/src/commands/identity_archive.rs` + `events.rs` | Kind 9035/9036. Bridge now mirrors. | +| Managed agent message | `desktop/src-tauri/src/commands/messages.rs` | Kind 9 signed as agent + optional `x-auth-tag`. Bridge now mirrors when `privateKeyHex` is seeded. | +| Workflows | `desktop/src-tauri/src/commands/workflows.rs` + `events.rs` | **Do publish** 30620 / 5 / 46020. Bridge still mock-only → follow-up. | +| Local archive | `desktop/src-tauri/src/archive/mod.rs` | **No mutation publish**; SQLite + query. Bridge local-only confirmed. | + ## Limits The card's owner metadata is still injected via @@ -370,8 +247,16 @@ The evidence message is real relay state, and both Accept/Reject clicks invoke the real relay-aware bridge reaction path. Publishing a valid owner-authenticated kind-0 profile for these test identities requires owner-authentication/signing plumbing that the headless harness does not -currently provide. This record therefore supports the narrower claim: +currently provide for the evidence card path. This record therefore supports +the narrower claim: **a headless evidence-card click publishes a real kind-7 to a real relay**. -It does not claim agent-profile provenance from relay state. +It does not claim agent-profile provenance from relay state for that card. + +For managed-agent channel messages (#144), the bridge **does** compute and +send the owner NIP-OA `x-auth-tag` header (empty conditions, matching the +Rust legacy path). Specs seed `managedAgents[].privateKeyHex` so the agent +can sign; without it, relay mode fails closed with an explicit error. -The relay remained running after verification at `http://localhost:3000`. +`send_channel_user_input_answer` in relay mode requires the 46040 request to +be queryable on the real relay (the #144 spec publishes it with Alice's +NIP-OA auth header first). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6ddbc33fb8c..3d692c7f0be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -247,6 +247,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@noble/curves': + specifier: 2.0.1 + version: 2.0.1 '@noble/hashes': specifier: ^2.0.1 version: 2.2.0