From cf2cca2c78aefc3f7a6d8c729c45b00f54c4d5ce Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 19:19:43 -0500 Subject: [PATCH 1/3] fix(mcp): retain current attachments when editing Resolve the versioned effective edit snapshot as the agent before publishing. Preserve empty replacement sets and fail closed on lookup errors. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/agents.md | 19 ++ docs/buzz-help.md | 4 + src/bundled/bestie/testing.ts | 1 + src/bundled/builderlab/index.test.tsx | 1 + src/buzz-mcp/app-client.test.ts | 24 ++ src/buzz-mcp/app-client.ts | 6 + src/buzz-mcp/client.ts | 2 + src/buzz-mcp/edit.test.ts | 216 ++++++++++++++++++ src/buzz-mcp/node.ts | 14 ++ src/buzz-mcp/tools.test.ts | 5 + src/buzz-mcp/tools.ts | 13 +- .../agents/local-activity.integration.test.ts | 1 + src/features/agents/native.test.ts | 54 +++++ src/features/agents/native.ts | 16 ++ src/features/agents/runner.ts | 5 + src/features/agents/service-testing.ts | 3 + src/features/agents/service.test.ts | 14 ++ src/features/agents/service.ts | 1 + src/features/relay/effective-edit.ts | 45 ++++ 19 files changed, 442 insertions(+), 2 deletions(-) create mode 100644 src/buzz-mcp/edit.test.ts create mode 100644 src/features/relay/effective-edit.ts diff --git a/docs/agents.md b/docs/agents.md index 9531b32812..187a1fa17b 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -76,6 +76,25 @@ same local bound and lifetime. Keys stay in native custody. Hosted-provider provisioning and standalone tool servers do not support these operations. +## Message edits + +The shared MCP `edit` tool retains the latest live attachment set, including an +empty set left by a previous edit. Both app-hosted and standalone agents resolve +that state as the agent through the version-1 `effective_edit` primary lookup on +`/query`; they verify the signed original/replacement and their target/channel. +The exact filter uses kinds `[9,40002,40008]`. Deploy the relay support from +[buzz#9226](https://github.com/block/buzz/pull/9226) before releasing this client. +An old relay's array response, inaccessible target, invalid response or failed +lookup aborts without publishing. There is no original-attachment fallback. + +The tool waits until the second after the observed head before requesting a +signature, reusing the bounded wait for notes/canvas. If that second is over five +seconds ahead, it asks the caller to retry when the clock catches up. Concurrent +writers still use relay ordering; this is not compare-and-swap. The relay must +authorize every carried file for the signing agent; an owner's access does not +grant the agent private-media access, and denial never causes an automatic retry +with files stripped. + ## External app authorization The build flag `BUZZ_EXTERNAL_MCP_COMMUNITIES` lists the allowed community diff --git a/docs/buzz-help.md b/docs/buzz-help.md index ab03580752..2063eadcb4 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -1203,6 +1203,10 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents. standalone Buzz MCP server checks that channel's members and privacy against the key its relay advertises; where the relay does not advertise one, the tools find none. +- Agent text edits retain the message's current attachments, including removals + made by an earlier edit. Editing requires a community that supports effective + edit lookup; if that read fails, the tool leaves the message alone. The agent + must still have permission to publish every retained file. - The community authorizes reads and writes. An agent's name, profile, picture, or appearance in a list does not prove access, ownership, or running status. When helping someone explore a community, use only your available access and diff --git a/src/bundled/bestie/testing.ts b/src/bundled/bestie/testing.ts index 779ff2769c..1ed99a4a16 100644 --- a/src/bundled/bestie/testing.ts +++ b/src/bundled/bestie/testing.ts @@ -380,6 +380,7 @@ export async function bestieFixture( publish: vi.fn(), publishProfile: vi.fn(), query: vi.fn(), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(), upload: vi.fn(), media: vi.fn(), diff --git a/src/bundled/builderlab/index.test.tsx b/src/bundled/builderlab/index.test.tsx index a7eaa8160b..ef46a03851 100644 --- a/src/bundled/builderlab/index.test.tsx +++ b/src/bundled/builderlab/index.test.tsx @@ -118,6 +118,7 @@ function provideAgents( publish: vi.fn(), publishProfile: vi.fn(), query: vi.fn(), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(), upload: vi.fn(), media: vi.fn(), diff --git a/src/buzz-mcp/app-client.test.ts b/src/buzz-mcp/app-client.test.ts index 43bdc6bf39..06056244c7 100644 --- a/src/buzz-mcp/app-client.test.ts +++ b/src/buzz-mcp/app-client.test.ts @@ -158,6 +158,30 @@ it("carries connection authorities independently of query event authors", async expect(query).toHaveBeenCalledWith([{ kinds: [39000] }]); }); +it("reads effective edit state as the agent and withholds it after the turn retires", async () => { + const result = gate>>(); + const effectiveEdit = vi.fn(() => result.promise); + let live = true; + const client = appClient({ effectiveEdit } as unknown as AgentHandle, { + read: vi.fn(), + memories: async () => ({ entries: [], partial: false }), + check: () => { + if (!live) throw new Error("retired"); + }, + }); + const pending = client.effectiveEdit?.("target", "private"); + expect(effectiveEdit).toHaveBeenCalledWith("target", "private"); + live = false; + result.resolve({ id: "head" } as Awaited< + ReturnType + >); + await expect(pending).rejects.toThrow("retired"); + await expect(client.effectiveEdit?.("target", "private")).rejects.toThrow( + "retired", + ); + expect(effectiveEdit).toHaveBeenCalledOnce(); +}); + it("does not invent authority when a host has none", () => { const client = appClient({ pubkey: "agent" } as AgentHandle, { read: vi.fn(), diff --git a/src/buzz-mcp/app-client.ts b/src/buzz-mcp/app-client.ts index d76aeb935e..e131b3689a 100644 --- a/src/buzz-mcp/app-client.ts +++ b/src/buzz-mcp/app-client.ts @@ -138,6 +138,12 @@ export function appClient( check(); return events; }, + effectiveEdit: async (id, channel) => { + check(); + const event = await agent.effectiveEdit(id, channel); + check(); + return event; + }, searchProfiles: (query, limit) => { check(); return agent.searchProfiles(query, limit); diff --git a/src/buzz-mcp/client.ts b/src/buzz-mcp/client.ts index 834cd5d879..31302ce2fb 100644 --- a/src/buzz-mcp/client.ts +++ b/src/buzz-mcp/client.ts @@ -137,6 +137,8 @@ export interface BuzzClient { readonly signingAuthority?: string | undefined; /** Reads the community as the agent. Every filter names its kinds. */ query(filters: readonly Filter[]): Promise; + /** Latest live attachment state, read as this agent from a primary snapshot. */ + effectiveEdit?(id: string, channel: string): Promise; /** Finds profiles whose `name` or `display_name` matches `query`, best * match first. `undefined` when the community has no profile name search; * NIP-50 search, which matches the whole profile, is the fallback. */ diff --git a/src/buzz-mcp/edit.test.ts b/src/buzz-mcp/edit.test.ts new file mode 100644 index 0000000000..40f670bea3 --- /dev/null +++ b/src/buzz-mcp/edit.test.ts @@ -0,0 +1,216 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { bytesToHex } from "nostr-tools/utils"; +import { eventDto } from "../features/relay/events"; +import { keypair, signed } from "../features/relay/testing"; +import { nodeClient } from "./node"; +import { callTool } from "./tools"; +import type { BuzzEvent } from "./client"; + +const agent = keypair(); +const owner = keypair(); +const channel = "00000000-0000-4000-8000-000000000001"; +const file = [ + "imeta", + "url https://relay.example.org/media/private.png", + `x ${"a".repeat(64)}`, +]; + +function fixture(kind = 9) { + vi.useFakeTimers(); + vi.setSystemTime(1_800_000_000_000); + const original = signed(agent, { + kind, + content: "original", + tags: [["h", channel], file], + created_at: 1_799_999_999, + }); + let latest: BuzzEvent | null = null; + let answer: unknown; + let lookupStatus = 200; + let accepted = true; + const published: BuzzEvent[] = []; + const fetcher = vi.fn(async (url: string, init: RequestInit) => { + const body = JSON.parse(String(init.body)); + if (url.endsWith("/events")) { + const event = eventDto(body); + published.push(event); + if (accepted) latest = event; + return Response.json({ + accepted, + message: accepted ? "" : "file not publishable by signer", + }); + } + const auth = String( + (init.headers as Record).authorization, + ).slice(6); + expect( + eventDto(JSON.parse(Buffer.from(auth, "base64").toString())).pubkey, + ).toBe(agent.pubkey); + if (body[0].effective_edit) { + expect(body).toEqual([ + { + kinds: [9, 40002, 40008], + ids: [original.id], + "#h": [channel], + effective_edit: 1, + consistency: "strong", + }, + ]); + return Response.json( + answer === undefined + ? { effective_edit: 1, original, edit: latest } + : answer, + { status: lookupStatus }, + ); + } + return Response.json([original]); + }); + vi.stubGlobal("fetch", fetcher); + const client = nodeClient({ + relay: "wss://relay.example.org", + key: bytesToHex(agent.secret), + auth: JSON.stringify(["auth", owner.pubkey, "", "0".repeat(128)]), + root: "/", + }); + return { + original, + client, + published, + fetcher, + edit: () => + callTool(client, { channel }, "edit", { + id: original.id, + text: "edited", + }), + setLatest: (tags: string[][], created_at = 1_800_000_000) => { + latest = signed(agent, { + kind: 40003, + content: "prior", + tags: [ + ["h", channel], + ["e", "invalid"], + ["e", original.id.toUpperCase()], + ...tags, + ], + created_at, + }); + return latest; + }, + answer: (value: unknown, status = 200) => { + answer = value; + lookupStatus = status; + }, + refuse: () => { + accepted = false; + }, + }; +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +it.each([9, 40002, 40008])( + "retains current private attachments across successive MCP edits of kind %s", + async (kind) => { + const f = fixture(kind); + await f.edit(); + expect(f.published[0]?.tags).toContainEqual(file); + const replacement = [ + "imeta", + "url https://relay.example.org/media/replacement.png", + `x ${"b".repeat(64)}`, + ]; + const head = f.setLatest([replacement]); + const second = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await second; + expect(f.published[1]?.created_at).toBeGreaterThan(head.created_at); + expect(f.published[1]?.tags.filter((t) => t[0] === "imeta")).toEqual([ + replacement, + ]); + f.setLatest([], 1_800_000_001); + const third = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await third; + const fourth = f.edit(); + await vi.advanceTimersByTimeAsync(1000); + await fourth; + expect( + f.published.slice(2).map((e) => e.tags.filter((t) => t[0] === "imeta")), + ).toEqual([[], []]); + expect( + f.published.every( + (e) => + e.pubkey === agent.pubkey && + e.tags.some((t) => t[0] === "auth" && t[1] === owner.pubkey), + ), + ).toBe(true); + }, +); + +it.each([ + ["old relay array", [], 200], + ["missing edit", { effective_edit: 1 }, 200], + ["wrong version", { effective_edit: 2, edit: null }, 200], + ["404", { error: "message not found" }, 404], + ["lookup failure", { error: "database unavailable" }, 503], +])( + "aborts before message signing/publication on %s", + async (_, response, status) => { + const f = fixture(); + f.answer(response, status as number | undefined); + await expect(f.edit()).rejects.toThrow(); + expect(f.published).toEqual([]); + expect( + f.fetcher.mock.calls.every(([url]) => !url.endsWith("/events")), + ).toBe(true); + }, +); + +it.each([ + "target", + "channel", + "kind", + "signature", + "replacement target", + "replacement channel", +])("rejects a signed snapshot with invalid %s", async (fault) => { + const f = fixture(); + let original = f.original; + let edit: BuzzEvent | null = null; + if (fault === "target") + original = signed(agent, { ...original, content: "another" }); + if (fault === "channel") + original = signed(agent, { ...original, tags: [["h", "elsewhere"]] }); + if (fault === "kind") original = signed(agent, { ...original, kind: 7 }); + if (fault === "signature") original = { ...original, sig: "0".repeat(128) }; + if (fault.startsWith("replacement")) + edit = signed(agent, { + kind: 40003, + content: "bad", + tags: [ + ["h", fault.endsWith("channel") ? "elsewhere" : channel], + ["e", fault.endsWith("target") ? "f".repeat(64) : original.id], + ], + }); + f.answer({ effective_edit: 1, original, edit }); + await expect(f.edit()).rejects.toThrow(); + expect(f.published).toEqual([]); +}); + +it("refuses an excessively future head instead of extending its timeline", async () => { + const f = fixture(); + f.setLatest([file], 1_800_000_010); + await expect(f.edit()).rejects.toThrow(/clock catches up/); + expect(f.published).toEqual([]); +}); + +it("reports signer media denial without retrying or stripping the attachments", async () => { + const f = fixture(); + f.refuse(); + await expect(f.edit()).rejects.toThrow("file not publishable by signer"); + expect(f.published).toHaveLength(1); + expect(f.published[0]?.tags).toContainEqual(file); +}); diff --git a/src/buzz-mcp/node.ts b/src/buzz-mcp/node.ts index 7cf2bad803..35b1b582f7 100644 --- a/src/buzz-mcp/node.ts +++ b/src/buzz-mcp/node.ts @@ -14,6 +14,10 @@ import { finalizeEvent, getPublicKey } from "nostr-tools/pure"; import { hexToBytes } from "nostr-tools/utils"; import { CANVAS_MAX_EVENT_BYTES } from "../features/channel-templates/canvas-limits.ts"; import { eventsDto } from "../features/relay/events"; +import { + effectiveEditDto, + effectiveEditFilter, +} from "../features/relay/effective-edit"; import type { BuzzClient, BuzzEvent, @@ -166,6 +170,16 @@ export function nodeClient( key: pubkey, query: async (filters) => eventsDto(await request("/query", JSON.stringify(filters))), + async effectiveEdit(id, channel) { + return effectiveEditDto( + await request( + "/query", + JSON.stringify(effectiveEditFilter(id, channel)), + ), + id, + channel, + ); + }, async searchProfiles(query, limit) { const response = await send( "/profiles/search", diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 9c467f563d..2e7af08b41 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -89,6 +89,11 @@ function fake( const client: BuzzClient = { key: me, pubkey: me, + effectiveEdit: vi.fn(async (id: string) => { + const found = events.find((event) => event.id === id); + if (!found) throw new Error("No effective edit fixture"); + return found; + }), query: vi.fn(async (filters: readonly Filter[]) => { const newest = [...events, ...kept].sort( (a, b) => b.created_at - a.created_at || a.id.localeCompare(b.id), diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 1221564755..f01fe7abf4 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -639,14 +639,23 @@ const HANDLERS: Readonly> = { const channel = tag(target, "h"); if (!channel) throw new Error(`${id} is not a channel message.`); const content = required(args, "text"); + if (!tools.client.effectiveEdit) + throw new Error("This host cannot obtain effective edit state."); + const effective = await tools.client.effectiveEdit(id, channel); + const after = (effective.created_at + 1) * 1000; + if (after - Date.now() > 5000) + throw new Error( + "The message was just changed; try again when the clock catches up.", + ); + await waitUntil(after); await tools.client.publish({ kind: 40003, content, - // An edit replaces the attachments too, so it keeps the message's. + // An edit replaces attachments too, including an explicitly empty set. tags: [ ["h", channel], ["e", id], - ...target.tags.filter((t) => t[0] === "imeta"), + ...effective.tags.filter((t) => t[0] === "imeta"), ...(await tools.emoji(content)), ], }); diff --git a/src/features/agents/local-activity.integration.test.ts b/src/features/agents/local-activity.integration.test.ts index 0e38aef948..11f8532a5a 100644 --- a/src/features/agents/local-activity.integration.test.ts +++ b/src/features/agents/local-activity.integration.test.ts @@ -89,6 +89,7 @@ it("carries a verified mention through host handover into all three scopes witho forget: vi.fn(), publish: vi.fn(), query: vi.fn(async () => []), + effectiveEdit: vi.fn(), searchProfiles: vi.fn(async () => undefined), upload: vi.fn(), remember: vi.fn(), diff --git a/src/features/agents/native.test.ts b/src/features/agents/native.test.ts index e8b90aa279..c8ff07f8d4 100644 --- a/src/features/agents/native.test.ts +++ b/src/features/agents/native.test.ts @@ -113,6 +113,60 @@ it("admits the community's events through the relay verifier", async () => { }); }); +it("reads effective edit state through the agent-authenticated native query", async () => { + const edit = signed(agent, { + kind: 40003, + content: "no files", + tags: [ + ["h", "c"], + ["e", message.id.toUpperCase()], + ], + }); + vi.mocked(invoke).mockResolvedValueOnce({ + effective_edit: 1, + original: message, + edit, + }); + const current = await native.effectiveEdit(member, message.id, "c"); + expect(current.id).toBe(edit.id); + expect(hasEventProof(current)).toBe(true); + expect(current.tags.filter((t) => t[0] === "imeta")).toEqual([]); + expect(invoke).toHaveBeenLastCalledWith("app_agent_query", { + ...member, + filters: [ + { + kinds: [9, 40002, 40008], + ids: [message.id], + "#h": ["c"], + effective_edit: 1, + consistency: "strong", + }, + ], + }); +}); + +it.each([ + ["old relay", [message]], + [ + "unsigned original", + { + effective_edit: 1, + original: { ...message, sig: "0".repeat(128) }, + edit: null, + }, + ], + ["wrong channel", { effective_edit: 1, original: message, edit: null }], +])("rejects effective state from %s", async (fault, response) => { + vi.mocked(invoke).mockResolvedValueOnce(response); + await expect( + native.effectiveEdit( + member, + message.id, + fault === "wrong channel" ? "other" : "c", + ), + ).rejects.toThrow(); +}); + it.each([ ["a forged signature", [{ ...message, sig: "0".repeat(128) }]], ["edited content", [{ ...message, content: "bye" }]], diff --git a/src/features/agents/native.ts b/src/features/agents/native.ts index 567d09e2f0..08ff4b0c8e 100644 --- a/src/features/agents/native.ts +++ b/src/features/agents/native.ts @@ -4,6 +4,7 @@ import type { } from "../agent-protection/service"; import { invoke, isTauri } from "@tauri-apps/api/core"; import { eventsDto, type RelayEvent } from "../relay/events"; +import { effectiveEditDto, effectiveEditFilter } from "../relay/effective-edit"; import { importedEmojiPicture } from "./import-picture"; /** A plugin agent's identity in one community as native custody reports it; @@ -189,6 +190,11 @@ export type AgentsNative = { publish(member: Membership, event: AgentEventTemplate): Promise; /** Reads the membership's community as the agent: one to eight filters. */ query(member: Membership, filters: readonly object[]): Promise; + effectiveEdit( + member: Membership, + id: string, + channel: string, + ): Promise; /** Finds profiles by `name` or `display_name` in the agent's community, as * the agent, best match first. `undefined` when the community has no * profile name search. */ @@ -309,6 +315,16 @@ export const nativeAgents = (): AgentsNative | undefined => eventsDto( await invoke("app_agent_query", { pubkey, relay, filters }), ), + effectiveEdit: async ({ pubkey, relay }, id, channel) => + effectiveEditDto( + await invoke("app_agent_query", { + pubkey, + relay, + filters: effectiveEditFilter(id, channel), + }), + id, + channel, + ), searchProfiles: async ({ pubkey, relay }, query, limit) => { const found = await invoke("app_agent_search_profiles", { pubkey, diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index 92440a42b2..c59a340d88 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -538,6 +538,7 @@ export type RunnerHost = { | "release" | "publish" | "query" + | "effectiveEdit" | "searchProfiles" | "upload" | "media" @@ -2889,6 +2890,10 @@ export class AgentRunners { lifetime.throwIfAborted(); return this.require().query(agent, filters); }, + effectiveEdit: async (id: string, channel: string) => { + lifetime.throwIfAborted(); + return this.require().effectiveEdit(agent, id, channel); + }, searchProfiles: async (query: string, limit: number) => { lifetime.throwIfAborted(); return this.require().searchProfiles(agent, query, limit); diff --git a/src/features/agents/service-testing.ts b/src/features/agents/service-testing.ts index c3dcb77acd..b3f52606d4 100644 --- a/src/features/agents/service-testing.ts +++ b/src/features/agents/service-testing.ts @@ -540,6 +540,9 @@ export function fakeNative(identities: AgentIdentity[] = []) { ), publishProfile: vi.fn(async (_: Membership) => {}), query: vi.fn(async () => []), + effectiveEdit: vi.fn(async () => { + throw new Error("No effective edit fixture"); + }), searchProfiles: vi.fn(async () => undefined), upload: vi.fn(async () => ({ url: "", sha256: "", size: 0, type: "" })), media: vi.fn(async () => ({ path: "", type: "", size: 0 })), diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index ce50c12167..9768b9b692 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -11488,6 +11488,20 @@ async function handleOf( return run.mock.calls.at(-1)?.[0].agent as Delivery["agent"]; } +it("routes effective edit lookups through the running agent's custody", async () => { + const { service, native, run, emit } = await setup(); + await service.create({ type: "example/echo", name: "Echo" }); + const handle = await handleOf(run, emit); + const head = event("head", { kind: 40003, tags: [["h", channel]] }); + native.effectiveEdit.mockResolvedValueOnce(head); + expect(await handle.effectiveEdit("target", channel)).toBe(head); + expect(native.effectiveEdit).toHaveBeenCalledWith( + expect.objectContaining({ pubkey: bot, relay: expect.any(String) }), + "target", + channel, + ); +}); + it("lets a run read and write its attention with expected-state tokens", async () => { const { service, run, emit } = await setup(); await service.create({ type: "example/echo", name: "Echo" }); diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 37fd27176a..394e56234d 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -495,6 +495,7 @@ export type AgentHandle = Readonly<{ publish(event: AgentEventTemplate, check?: () => void): Promise; /** Reads the community as the agent, seeing only what it may see. */ query(filters: readonly object[]): Promise; + effectiveEdit(id: string, channel: string): Promise; /** Finds profiles by `name` or `display_name`, as the agent, best match * first. `undefined` when its community has no profile name search. */ searchProfiles( diff --git a/src/features/relay/effective-edit.ts b/src/features/relay/effective-edit.ts new file mode 100644 index 0000000000..b607e1524a --- /dev/null +++ b/src/features/relay/effective-edit.ts @@ -0,0 +1,45 @@ +import { eventDto } from "./events"; + +/** Versioned primary snapshot; a generic #e read cannot resolve mixed-case targets. */ +export function effectiveEditFilter(id: string, channel: string) { + return [ + { + kinds: [9, 40002, 40008], + ids: [id], + "#h": [channel], + effective_edit: 1, + consistency: "strong", + }, + ]; +} + +/** Empty replacement tags are authoritative, never a reason to use the original. */ +export function effectiveEditDto(value: unknown, id: string, channel: string) { + if ( + !value || + typeof value !== "object" || + Array.isArray(value) || + !("effective_edit" in value) || + value.effective_edit !== 1 || + !("original" in value) || + !("edit" in value) + ) + throw new Error("Relay must support effective_edit v1 before editing."); + const original = eventDto(value.original); + const inChannel = (event: typeof original) => + event.tags.find((t) => t[0] === "h")?.[1] === channel; + if ( + original.id !== id || + ![9, 40002, 40008].includes(original.kind) || + !inChannel(original) + ) + throw new Error("Effective edit response has the wrong original."); + if (value.edit === null) return original; + const edit = eventDto(value.edit); + const target = edit.tags.find( + (t) => t[0] === "e" && /^[0-9a-f]{64}$/i.test(t[1] ?? ""), + )?.[1]; + if (edit.kind !== 40003 || !inChannel(edit) || target?.toLowerCase() !== id) + throw new Error("Effective edit response has the wrong replacement."); + return edit; +} From ea472c76c8fa5440cf03ac55070b88e41ad90894 Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 19:25:49 -0500 Subject: [PATCH 2/3] fix(mcp): preserve forum message edit support Keep the effective lookup contract aligned with the five existing editable message kinds. Cover forum edits and unsupported targets without changing ingest policy. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/agents.md | 2 +- src/buzz-mcp/edit.test.ts | 13 +++++++++++-- src/features/agents/native.test.ts | 2 +- src/features/relay/effective-edit.ts | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 187a1fa17b..2beee060ea 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -82,7 +82,7 @@ The shared MCP `edit` tool retains the latest live attachment set, including an empty set left by a previous edit. Both app-hosted and standalone agents resolve that state as the agent through the version-1 `effective_edit` primary lookup on `/query`; they verify the signed original/replacement and their target/channel. -The exact filter uses kinds `[9,40002,40008]`. Deploy the relay support from +The exact filter uses kinds `[9,40002,40008,45001,45003]`. Deploy the relay support from [buzz#9226](https://github.com/block/buzz/pull/9226) before releasing this client. An old relay's array response, inaccessible target, invalid response or failed lookup aborts without publishing. There is no original-attachment fallback. diff --git a/src/buzz-mcp/edit.test.ts b/src/buzz-mcp/edit.test.ts index 40f670bea3..ea350c525e 100644 --- a/src/buzz-mcp/edit.test.ts +++ b/src/buzz-mcp/edit.test.ts @@ -49,7 +49,7 @@ function fixture(kind = 9) { if (body[0].effective_edit) { expect(body).toEqual([ { - kinds: [9, 40002, 40008], + kinds: [9, 40002, 40008, 45001, 45003], ids: [original.id], "#h": [channel], effective_edit: 1, @@ -111,7 +111,7 @@ afterEach(() => { vi.unstubAllGlobals(); }); -it.each([9, 40002, 40008])( +it.each([9, 40002, 40008, 45001, 45003])( "retains current private attachments across successive MCP edits of kind %s", async (kind) => { const f = fixture(kind); @@ -207,6 +207,15 @@ it("refuses an excessively future head instead of extending its timeline", async expect(f.published).toEqual([]); }); +it.each([40003, 7, 45002])( + "rejects unsupported original kind %s without publication", + async (kind) => { + const f = fixture(kind); + await expect(f.edit()).rejects.toThrow("wrong original"); + expect(f.published).toEqual([]); + }, +); + it("reports signer media denial without retrying or stripping the attachments", async () => { const f = fixture(); f.refuse(); diff --git a/src/features/agents/native.test.ts b/src/features/agents/native.test.ts index c8ff07f8d4..94730a8b59 100644 --- a/src/features/agents/native.test.ts +++ b/src/features/agents/native.test.ts @@ -135,7 +135,7 @@ it("reads effective edit state through the agent-authenticated native query", as ...member, filters: [ { - kinds: [9, 40002, 40008], + kinds: [9, 40002, 40008, 45001, 45003], ids: [message.id], "#h": ["c"], effective_edit: 1, diff --git a/src/features/relay/effective-edit.ts b/src/features/relay/effective-edit.ts index b607e1524a..731fe2bcb5 100644 --- a/src/features/relay/effective-edit.ts +++ b/src/features/relay/effective-edit.ts @@ -4,7 +4,7 @@ import { eventDto } from "./events"; export function effectiveEditFilter(id: string, channel: string) { return [ { - kinds: [9, 40002, 40008], + kinds: [9, 40002, 40008, 45001, 45003], ids: [id], "#h": [channel], effective_edit: 1, @@ -30,7 +30,7 @@ export function effectiveEditDto(value: unknown, id: string, channel: string) { event.tags.find((t) => t[0] === "h")?.[1] === channel; if ( original.id !== id || - ![9, 40002, 40008].includes(original.kind) || + ![9, 40002, 40008, 45001, 45003].includes(original.kind) || !inChannel(original) ) throw new Error("Effective edit response has the wrong original."); From b206249ecb4f151703f2e56458b2b6900b32c0df Mon Sep 17 00:00:00 2001 From: Brad Seiler Date: Fri, 6 Nov 2026 19:39:06 -0500 Subject: [PATCH 3/3] test(mcp): cover fail-closed edit boundaries Reject missing host lookup support and signed replacements of the wrong kind in regression tests. Document the relay prerequisite, rollout signals, rollback risk, and bounded MCP timestamp wait. Co-authored-by: Honor Signed-off-by: Brad Seiler --- docs/agents.md | 15 +++++++++++---- src/buzz-mcp/edit.test.ts | 3 ++- src/buzz-mcp/tools.test.ts | 12 ++++++++++++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/agents.md b/docs/agents.md index 2beee060ea..feb100b596 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -82,15 +82,22 @@ The shared MCP `edit` tool retains the latest live attachment set, including an empty set left by a previous edit. Both app-hosted and standalone agents resolve that state as the agent through the version-1 `effective_edit` primary lookup on `/query`; they verify the signed original/replacement and their target/channel. -The exact filter uses kinds `[9,40002,40008,45001,45003]`. Deploy the relay support from +The exact filter uses kinds `[9,40002,40008,45001,45003]`. +Deploy the relay support from [buzz#9226](https://github.com/block/buzz/pull/9226) before releasing this client. An old relay's array response, inaccessible target, invalid response or failed -lookup aborts without publishing. There is no original-attachment fallback. +lookup aborts without publishing. All MCP agent edits stop working on a community +whose relay lacks this lookup; upgrade each community before releasing the client. +There is no original-attachment fallback. Observe MCP edit errors and the relay's +`buzz_media_publication_access_total` denials during rollout. Rolling the client +back restores the original-attachment resurrection bug. The tool waits until the second after the observed head before requesting a signature, reusing the bounded wait for notes/canvas. If that second is over five -seconds ahead, it asks the caller to retry when the clock catches up. Concurrent -writers still use relay ordering; this is not compare-and-swap. The relay must +seconds ahead, it asks the caller to retry when the clock catches up. +The MCP publisher stamps the current time, so its five-second wait bounds tool +latency. The CLI supplies an explicit timestamp and tolerates a 60-second lead. +Concurrent writers still use relay ordering; this is not compare-and-swap. The relay must authorize every carried file for the signing agent; an owner's access does not grant the agent private-media access, and denial never causes an automatic retry with files stripped. diff --git a/src/buzz-mcp/edit.test.ts b/src/buzz-mcp/edit.test.ts index ea350c525e..83ff71d42b 100644 --- a/src/buzz-mcp/edit.test.ts +++ b/src/buzz-mcp/edit.test.ts @@ -176,6 +176,7 @@ it.each([ "signature", "replacement target", "replacement channel", + "replacement kind", ])("rejects a signed snapshot with invalid %s", async (fault) => { const f = fixture(); let original = f.original; @@ -188,7 +189,7 @@ it.each([ if (fault === "signature") original = { ...original, sig: "0".repeat(128) }; if (fault.startsWith("replacement")) edit = signed(agent, { - kind: 40003, + kind: fault === "replacement kind" ? 7 : 40003, content: "bad", tags: [ ["h", fault.endsWith("channel") ? "elsewhere" : channel], diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 2e7af08b41..c9fb837cdf 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -628,6 +628,18 @@ it("posts at the channel's top level when given a channel", async () => { expect(published[0]?.tags).toEqual([["h", "other"]]); }); +it("refuses to edit when the host cannot obtain effective attachment state", async () => { + const { client, published } = fake(community); + delete client.effectiveEdit; + await expect( + callTool(client, { channel: "chan" }, "edit", { + id: root, + text: "must not copy the original's attachments", + }), + ).rejects.toThrow("cannot obtain effective edit state"); + expect(published).toEqual([]); +}); + it("tags the community emoji a message or edit uses, as the composer does", async () => { const stamp = "https://relay/media/stamp.png"; const emojiSet = (pubkey: string, created_at: number, entries: string[][]) =>