diff --git a/docs/agents.md b/docs/agents.md index 8894819cd3..98f0544dff 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -76,38 +76,6 @@ same local bound and lifetime. Keys stay in native custody. Hosted-provider provisioning and standalone tool servers do not support these operations. -## Owner tools - -In communities the build flag `BUZZ_OWNER_MCP_COMMUNITIES` enables, an agent's -runs in this app on a bundled harness list `owner_channel_list`, `owner_channel_get`, -`owner_member_list`, `owner_read`, `owner_canvas_get` and `owner_canvas_history`. -They read the owner's channels, one channel's details and -the owner's rights in it, its roster, its messages, whole threads and searches, -and its Canvas and earlier revisions, through the -owner's own signed-in session, including private and archived channels, DMs and -Solo chats the agent is not in. Message reads and Canvas history page with a -`next` cursor, as `read` and the app's history do. A search covers only the -owner's channels and says `partial` while their list is incomplete. Other -channels' matches still fill its page, so `limited` with few results means -narrowing it by channel or time. A message shows its newest edit by its author -or, as in the app, by the author's verified owner. One read fetches a page's edits; -when they fill it, the answer says `edits_limited` and some messages may show earlier text. They write -nothing: no membership, subscription, read state, post, agent start or Canvas -change. Content comes from the relay's own answer, not the owner's open views, -so it changes no unread counts. They do not save the owner's attachments: a -saved file would be readable before the call's final checks. The app -decides the agent, owner and community; -answers name them in `executedAs`. Each call needs the agent's owner signed in -to that community now and fails, returning nothing, when the account, community -or connection changes during it or the turn ends or is stopped, including while -protection reviews its result. Installed harness plugins, plugins' tools, plugin -MCP connections and external apps never get this access, and a plugin cannot add -a tool whose name starts with `owner_`. A [Mesh agent](../src/bundled/mesh-compute/MESH_AGENT.md) -has these tools too; as its setup warns of all tool output, members serving its -model can read what they return. Protection reviews these calls and their -results as it does other Buzz tools. See -[configuration](configuration.md#owner-mcp-community-flag). - ## External app authorization The build flag `BUZZ_EXTERNAL_MCP_COMMUNITIES` lists the allowed community @@ -380,7 +348,8 @@ attention is off. A new shared tool needs no harness change. Plugins add tools with `ctx.agents.addTool`, listed after the Buzz tools under the same `buzz` server. Names are shared by every plugin, so a plugin's tools should start with its name (`notes_get`); `addTool` refuses one that a Buzz tool -or another plugin has. The standalone server in +or another plugin has, and any starting with `owner_`, the prefix of retired +owner-access tools, so a call to one never reaches plugin code. The standalone server in `src/buzz-mcp/main.ts` stays without them. A harness reads `agents.tools()` and passes it to `respond` or `callSessionTool`. A session keeps the list it started with, so `addedKey` goes in what decides when one is replaced: the diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 58608781f8..e48574f4be 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -940,13 +940,6 @@ available rather than silently discarding it. no switch to turn on. A change shows as not saved when it is active but could not be saved on this device. -- In communities where your build enables it, an agent running on this - device with a built-in harness can list your channels, including private and - archived ones, DMs and Solo chats it is not in, and see a channel's details - and members, read with your access. It cannot change anything this way, and - it stops working when you sign out, switch accounts, disconnect or stop its - turn. - Saved teams from **Your teams** appear in the inline `@` suggestions and the mention picker. Select a team to insert its members as individual mentions. Typing its name alone does not notify it. Empty teams, teams with more than 32 diff --git a/docs/configuration.md b/docs/configuration.md index c73820f9c6..1f4033f311 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -9,7 +9,6 @@ Build defaults are readable binary data, **never secret storage**. | Input | Consumer and boundary | | --- | --- | | `BUZZ_EXTERNAL_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins allowed to authorize external MCP access. Empty disables access. See below. | -| `BUZZ_OWNER_MCP_COMMUNITIES` | Frontend build: space-separated secure community origins where an agent's app-hosted runs can read its owner's channels, members, messages and Canvases with the owner's access. Empty disables them. See below. | | `BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` | Frontend build: space-separated secure community origins where workflow messages cannot start or steer local agents. Empty keeps checked workflow delegation everywhere. See below. | | `BUZZ_BUILDERLAB_URL` | Shared frontend/native build: the live development broker's Builderlab, and whether a build without an added sign-in server keeps the built-in one; no runtime override. See below. | | `BUZZ_BUILDERLAB_APPS_DOMAIN` | Shared frontend/native build: exact Apps cookie domain paired with the login origin for native viewer SSO; no runtime override. | @@ -28,7 +27,9 @@ Build defaults are readable binary data, **never secret storage**. The retired v1 inputs `BUZZ_BUILD_AGENT_ENV`, `BUZZ_BUILD_BUZZ_AGENT_PROVIDER` and `BUZZ_BUILD_AGENT_ACCESS_OWNER_ONLY` are no longer read or compiled. Agents uses each plugin agent type's settings. Remove these old defaults from local/CI -configuration; they do not configure Agents. Restart Vite to change broker or +configuration; they do not configure Agents. `BUZZ_OWNER_MCP_COMMUNITIES`, +which enabled the retired owner-read agent tools, is no longer read either. +Restart Vite to change broker or routing configuration. Production frontend builds never load the dev broker or expose its auto-open seed. They can separately opt into the packaged community default below. @@ -45,16 +46,6 @@ repository's workflow sets none. Community names do not grant access. The Agents opening a connection and on each tool call. Consent disables other communities, and Settings hides External apps there. Normal agent MCP servers are unchanged. -## Owner MCP community flag - -`BUZZ_OWNER_MCP_COMMUNITIES` is a space-separated list of `https://` or -`wss://` community origins, parsed as the external flag is. The default is -empty, which turns the owner tools off in every community. Each build lists -its own community origins. It is not an employee entitlement and grants no external app or plugin access. -The Agents service compares the agent's exact canonical community origin when -it starts a run and again on each call. See -[owner tools](agents.md#owner-tools). - ## Workflow restriction community flag `BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES` is a space-separated list of `https://` diff --git a/docs/relay-queries.md b/docs/relay-queries.md index d28031d339..92c17ea6c8 100644 --- a/docs/relay-queries.md +++ b/docs/relay-queries.md @@ -93,14 +93,6 @@ state for their shared load/preflight/confirmation reads; the member dialog's separate display-roster load remains replica-eligible. Channel-write membership preflights (including session sends and canvas saves) also use the writer, so a just-added member does not fail the next operation. -`owner_channel_get` reads a channel's records from the writer once and judges -its archive state and lifecycle rights, including an owned agent's grant, from -those records. The other channel-scoped owner reads check the same records -before reading the channel's content as a snapshot read, which feeds no session -view; an unscoped `owner_read` search keeps only results in the owner's listed -channels. The first page of `owner_canvas_history` is the writer's, as the app's -history is, and `owner_canvas_get` reads an explicit revision from the writer, -so a revision that history just listed is found. Template setup also confirms exact Canvas/member events and selected Canvas heads against the writer without replaying accepted commands. Agent deletion discovers member channels and confirms each removal with writer-backed rosters; unreadable diff --git a/src/bundled/claude-code/claude.test.ts b/src/bundled/claude-code/claude.test.ts index 5d868aebdc..9ca2263fc5 100644 --- a/src/bundled/claude-code/claude.test.ts +++ b/src/bundled/claude-code/claude.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from "vitest"; import { appClient } from "../../buzz-mcp/app-client"; -import type { OwnerAccess } from "../../buzz-mcp/client"; import { callSessionTool } from "../../buzz-mcp/session-tools"; import type { AgentHandle } from "../../features/agents/service"; import { ClaudeProcess, stepLabel } from "./claude"; @@ -399,72 +398,44 @@ it("settles a stopped turn that Claude ends as aborted tools as stopped, not fai expect(claude.busy).toBe(false); }); -/** A Claude whose Buzz tools run through the real app client: `owner_channel_list` - * holds its first read until `release`, and any other tool reads a memory. +/** A Claude whose Buzz tools run through the real app client: its first + * `mem_get` of `later` holds until `release`, and other reads answer at once. * With `holdStops`, Claude answers a Stop only when the test does. */ -async function ownerTurns(env?: Record) { +async function heldTurns(env?: Record) { const { spawn, processes } = fakeSpawn(); const stops: string[] = []; let holdStops = false; - const listing = { - execution: { agent: "agent", owner: "owner", community: "relay" }, - channels: [ - { - id: "c", - name: "Owner secrets", - type: "stream" as const, - visibility: "private" as const, - archived: false, - }, - ], - partial: false, - }; let release: (() => void) | undefined; let entered!: () => void; const reading = new Promise((resolve) => { entered = resolve; }); - const channels = vi.fn(async () => { - if (channels.mock.calls.length === 1) { + let later = 0; + const memories = vi.fn(async (slug?: string) => { + if (slug === "mem/later" && ++later === 1) { entered(); await new Promise((resolve) => { release = resolve; }); } - return listing; + return { + entries: [{ slug: slug ?? "core", body: `${slug} notes`, createdAt: 1 }], + partial: false, + }; }); - const ownerAccess = { - channels, - channel: vi.fn(), - members: vi.fn(), - held: vi.fn(), - }; const signals: AbortSignal[] = []; const tools = vi.fn( async (_conversation: unknown, message: unknown, signal: AbortSignal) => { signals.push(signal); const { id, params } = message as { id: number; - params: { name: string }; + params: { name: string; arguments: Record }; }; const client = appClient( - { - pubkey: "agent", - key: "agent", - ownerAccess, - } as unknown as AgentHandle, - { - read: vi.fn(), - memories: async () => ({ - entries: [{ slug: "core", body: "core notes", createdAt: 1 }], - partial: false, - }), - signal, - }, + { pubkey: "agent", key: "agent" } as unknown as AgentHandle, + { read: vi.fn(), memories, signal }, ); - const name = params.name; - const args = name === "mem_get" ? { slug: "core" } : {}; - return callSessionTool(client, {}, name, args).then( + return callSessionTool(client, {}, params.name, params.arguments).then( (text) => ({ jsonrpc: "2.0", id, result: { content: [{ text }] } }), (error: Error) => ({ jsonrpc: "2.0", @@ -498,7 +469,7 @@ async function ownerTurns(env?: Record) { await vi.waitFor(() => expect(fake.received.some(of)).toBe(true)); return JSON.stringify(fake.received.find(of)); }; - const ask = (requestId: string, name = "owner_channel_list") => + const ask = (requestId: string, slug = "later") => fake.emit({ type: "control_request", request_id: requestId, @@ -509,7 +480,7 @@ async function ownerTurns(env?: Record) { jsonrpc: "2.0", id: requestId, method: "tools/call", - params: { name }, + params: { name: "mem_get", arguments: { slug } }, }, }, }); @@ -526,7 +497,7 @@ async function ownerTurns(env?: Record) { holdStops = true; }, answerStop, - ownerAccess, + memories, signals, reading, ask, @@ -535,11 +506,11 @@ async function ownerTurns(env?: Record) { }; } -it("withholds an owner read an unprotected Stop overtook, which later work cannot revive", async () => { - const t = await ownerTurns(); +it("withholds a read an unprotected Stop overtook, which later work cannot revive", async () => { + const t = await heldTurns(); try { t.fake.hold = true; - const stopped = t.claude.send("list my channels"); + const stopped = t.claude.send("check my notes"); await flush(); t.ask("r1"); await t.reading; @@ -547,8 +518,7 @@ it("withholds an owner read an unprotected Stop overtook, which later work canno t.release(); const refused = await t.answer("r1"); expect(refused).toContain('"isError":true'); - expect(refused).not.toContain("Owner secrets"); - expect(t.ownerAccess.held).not.toHaveBeenCalled(); + expect(refused).not.toContain("later notes"); abortTools(t.fake); await expect(stopped).resolves.toEqual({ ok: true, stopped: true }); @@ -556,7 +526,7 @@ it("withholds an owner read an unprotected Stop overtook, which later work canno void t.claude.send("again"); await flush(); t.ask("r2"); - expect(await t.answer("r2")).toContain("Owner secrets"); + expect(await t.answer("r2")).toContain("later notes"); expect(t.signals[0]?.aborted).toBe(true); expect(t.signals[1]?.aborted).toBe(false); } finally { @@ -566,10 +536,10 @@ it("withholds an owner read an unprotected Stop overtook, which later work canno }); it("answers a turn of its own after a Stop, while a call held across the Stop stays refused", async () => { - const t = await ownerTurns(); + const t = await heldTurns(); try { t.fake.hold = true; - const stopped = t.claude.send("list my channels"); + const stopped = t.claude.send("check my notes"); await flush(); t.ask("r1"); await t.reading; @@ -579,18 +549,17 @@ it("answers a turn of its own after a Stop, while a call held across the Stop st // A finished background task starts a turn with no message from Buzz. sessionState(t.fake, "running"); - t.ask("r2", "mem_get"); + t.ask("r2", "core"); t.ask("r3"); const memory = await t.answer("r2"); expect(memory).not.toContain('"isError":true'); expect(memory).toContain("core notes"); - expect(await t.answer("r3")).toContain("Owner secrets"); + expect(await t.answer("r3")).toContain("later notes"); t.release(); const refused = await t.answer("r1"); expect(refused).toContain('"isError":true'); - expect(refused).not.toContain("Owner secrets"); - expect(t.ownerAccess.held).toHaveBeenCalledTimes(1); + expect(refused).not.toContain("later notes"); sessionState(t.fake, "idle"); } finally { t.release(); @@ -603,21 +572,21 @@ it.each([ ])( "keeps a message sent while a Stop lands in the stopped turn, when Claude %s", async (_, env) => { - const t = await ownerTurns(env); + const t = await heldTurns(env); const idle = !env; t.fake.hold = true; - const stopped = t.claude.send("list my channels"); + const stopped = t.claude.send("check my notes"); await flush(); if (idle) sessionState(t.fake, "running"); await t.claude.interrupt(); - const folded = t.claude.send("and my DMs"); + const folded = t.claude.send("and the later ones"); await flush(); // A call the stopped turn makes late is still stopped. - t.ask("r1", "mem_get"); + t.ask("r1", "core"); t.ask("r2"); expect(await t.answer("r1")).toContain('"isError":true'); expect(await t.answer("r2")).toContain('"isError":true'); - expect(t.ownerAccess.channels).not.toHaveBeenCalled(); + expect(t.memories).not.toHaveBeenCalled(); let settled = false; void stopped.then(() => { settled = true; @@ -630,25 +599,25 @@ it.each([ await expect(stopped).resolves.toEqual({ ok: true, stopped: true }); await expect(folded).resolves.toEqual({ ok: true, stopped: true }); - // The next message is new work, with ordinary and owner calls. + // The next message is new work, whose calls answer. const next = t.claude.send("next"); await flush(); - t.ask("r3", "mem_get"); + t.ask("r3", "core"); t.ask("r4"); expect(await t.answer("r3")).toContain("core notes"); await t.reading; t.release(); - expect(await t.answer("r4")).toContain("Owner secrets"); + expect(await t.answer("r4")).toContain("later notes"); t.fake.finish(); await expect(next).resolves.toEqual({ ok: true }); }, ); it("stops a turn that starts while an earlier Stop awaits Claude's answer", async () => { - const t = await ownerTurns(); + const t = await heldTurns(); t.holdStops(); t.fake.hold = true; - const sent = t.claude.send("list my channels"); + const sent = t.claude.send("check my notes"); await flush(); const first = t.claude.interrupt(); abortTools(t.fake); @@ -658,7 +627,7 @@ it("stops a turn that starts while an earlier Stop awaits Claude's answer", asyn sessionState(t.fake, "running"); const second = t.claude.interrupt(); expect(t.stops).toHaveLength(2); - t.ask("r1", "mem_get"); + t.ask("r1", "core"); expect(await t.answer("r1")).toContain('"isError":true'); t.answerStop(t.stops[0]); t.answerStop(t.stops[1]); @@ -668,10 +637,10 @@ it("stops a turn that starts while an earlier Stop awaits Claude's answer", asyn }); it("asks Claude once for Stops made together, which share its refusal", async () => { - const t = await ownerTurns(); + const t = await heldTurns(); t.fake.hold = true; t.fake.interruptError = "not now"; - const sent = t.claude.send("list my channels"); + const sent = t.claude.send("check my notes"); await flush(); const first = t.claude.interrupt(); const second = t.claude.interrupt(); @@ -679,23 +648,23 @@ it("asks Claude once for Stops made together, which share its refusal", async () await expect(second).rejects.toThrow("Claude did not stop: not now"); expect(interrupts(t.fake)).toHaveLength(1); // Claude refused, so the turn and its calls go on. - t.ask("r1", "mem_get"); + t.ask("r1", "core"); expect(await t.answer("r1")).toContain("core notes"); t.fake.finish(); await expect(sent).resolves.toEqual({ ok: true }); }); it("keeps work stopped when Claude refuses a repeated Stop", async () => { - const t = await ownerTurns(); + const t = await heldTurns(); t.fake.hold = true; - const sent = t.claude.send("list my channels"); + const sent = t.claude.send("check my notes"); await flush(); await t.claude.interrupt(); t.fake.interruptError = "already stopping"; await expect(t.claude.interrupt()).rejects.toThrow( "Claude did not stop: already stopping", ); - t.ask("r1", "mem_get"); + t.ask("r1", "core"); expect(await t.answer("r1")).toContain('"isError":true'); abortTools(t.fake); await expect(sent).resolves.toEqual({ ok: true, stopped: true }); diff --git a/src/bundled/claude-code/runtime.ts b/src/bundled/claude-code/runtime.ts index 945371b2a7..2eae4986dd 100644 --- a/src/bundled/claude-code/runtime.ts +++ b/src/bundled/claude-code/runtime.ts @@ -162,8 +162,8 @@ const where = ({ workspace, model, effort }: Config) => ({ /** How Claude lists the session tools: in full up front only those marked * `alwaysLoad`, and the rest by name until its tool search loads them, with * the server's instructions saying what they are. */ -const listing = (added: readonly AddedTool[], owner = false): Listing => ({ - instructions: sessionHint(added, owner), +const listing = (added: readonly AddedTool[]): Listing => ({ + instructions: sessionHint(added), tool: ({ load, ...tool }) => load === "always" ? { ...tool, _meta: { "anthropic/alwaysLoad": true } } @@ -1412,7 +1412,7 @@ export class ClaudeRuntime { context, message, this.added(), - listing(this.added(), !!client?.ownerAccess), + listing(this.added()), ); }; const saved = this.savedSessions(agent); diff --git a/src/bundled/codex/runtime.test.ts b/src/bundled/codex/runtime.test.ts index d8373d36a1..35785e266e 100644 --- a/src/bundled/codex/runtime.test.ts +++ b/src/bundled/codex/runtime.test.ts @@ -7032,66 +7032,46 @@ it.each(["conversation", "standalone", "preview"])( }, ); -it.each([true, false])( - "lists and runs the owner tools only for a run with owner access: %s", - async (owned) => { - const f = fixture(); - const executedAs = { agent: "agent", owner: "owner", community: "c" }; - const ownerAccess = { - channels: vi.fn(async () => ({ - execution: executedAs, - channels: [], - partial: false, - })), - channel: vi.fn(), - members: vi.fn(), - messages: vi.fn(), - canvas: vi.fn(), - canvasHistory: vi.fn(), - held: vi.fn(), - }; - const delivery = f.delivery("@Codex list my channels"); - await f.runtime.run({ - ...delivery, - agent: { - ...delivery.agent, - ...(owned ? { ownerAccess } : {}), - } as typeof delivery.agent, - }); - await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); - const thread = f.sent.find((wire) => wire.method === "thread/start") - ?.params as { - dynamicTools: { - description: string; - tools: { name: string; deferLoading?: boolean }[]; - }[]; - }; - const listed = thread.dynamicTools[0]?.tools.filter((tool) => - tool.name.startsWith("owner_"), - ); - expect(listed).toEqual( - owned - ? [ - "owner_channel_list", - "owner_channel_get", - "owner_member_list", - "owner_read", - "owner_canvas_get", - "owner_canvas_history", - ].map((name) => expect.objectContaining({ name, deferLoading: true })) - : [], - ); - // A name the model remembers or guesses is checked against the run's - // own access on every call. - const reply = (await f.tool("owner_channel_list", {}))?.result as { +// The owner-read tools #2745 and #2881 added, listed here rather than taken +// from the catalog under test. +const RETIRED_OWNER_TOOLS = [ + "owner_channel_list", + "owner_channel_get", + "owner_member_list", + "owner_read", + "owner_canvas_get", + "owner_canvas_history", +]; + +it("neither lists nor runs the retired owner-read tools, while its own reads work", async () => { + const f = fixture(); + // A handle that still carries the old capability must not make it usable. + const ownerRead = vi.fn(); + const ownerAccess = new Proxy({}, { get: () => ownerRead }); + const delivery = f.delivery("OWN_CONVERSATION"); + await f.runtime.run({ + ...delivery, + agent: { ...delivery.agent, ownerAccess } as typeof delivery.agent, + }); + await vi.waitFor(() => expect(f.starts()).toHaveLength(1)); + const thread = f.sent.find((wire) => wire.method === "thread/start") + ?.params as { + dynamicTools: { description: string; tools: { name: string }[] }[]; + }; + const listed = JSON.stringify(thread.dynamicTools); + for (const name of RETIRED_OWNER_TOOLS) { + expect(listed).not.toContain(name); + // A name the model remembers from an earlier build is refused. + const reply = (await f.tool(name, { channel: "channel" }))?.result as { success: boolean; contentItems: { text: string }[]; }; - expect(reply.success).toBe(owned); - expect(reply.contentItems[0]?.text).toContain( - owned ? '"executedAs"' : "nothing was read", - ); - expect(ownerAccess.channels).toHaveBeenCalledTimes(owned ? 1 : 0); - await f.complete(); - }, -); + expect(reply.success).toBe(false); + expect(reply.contentItems[0]?.text).toContain("No active Buzz tool"); + } + expect(ownerRead).not.toHaveBeenCalled(); + expect(JSON.stringify((await f.tool("read", {}))?.result)).toContain( + "OWN_CONVERSATION", + ); + await f.complete(); +}); diff --git a/src/bundled/codex/runtime.ts b/src/bundled/codex/runtime.ts index 54a81eafb5..36d0654a6a 100644 --- a/src/bundled/codex/runtime.ts +++ b/src/bundled/codex/runtime.ts @@ -1928,7 +1928,7 @@ export class CodexRuntime { { ...params, ephemeral: true, - dynamicTools: dynamicTools(this.added(), !!agent.ownerAccess), + dynamicTools: dynamicTools(this.added()), }, ); once.threadId = started.thread.id; @@ -2178,7 +2178,7 @@ export class CodexRuntime { if ( !turn || call.namespace !== "buzz" || - !sessionTools(this.added(), true).some((tool) => tool.name === call.tool) + !sessionTools(this.added()).some((tool) => tool.name === call.tool) ) throw new Error("No active Buzz tool for this thread and turn"); const check = () => { @@ -2581,15 +2581,14 @@ export class CodexRuntime { // Resume restores the thread's tools, so one started with other added // tools starts fresh. const added = this.added(); - const owner = !!agent.ownerAccess; - const tools = addedKey(added) + (owner ? ":owner" : ""); + const tools = addedKey(added); const resuming = existing?.workspace === workspace && (existing.tools ?? "") === tools; type Started = { thread: { id: string; cwd: string } }; const start = () => rpc.request("thread/start", { ...params, - dynamicTools: dynamicTools(added, owner), + dynamicTools: dynamicTools(added), }); const resume = (threadId: string) => rpc.request("thread/resume", { @@ -3002,12 +3001,12 @@ function follow( * keeps a deferred tool from the model until its tool search finds it. Every * model in Codex's catalog has tool search (supports_search_tool); one * without it would not see deferred tools. */ -const dynamicTools = (added: readonly AddedTool[], owner: boolean) => [ +const dynamicTools = (added: readonly AddedTool[]) => [ { type: "namespace", name: "buzz", - description: sessionHint(added, owner), - tools: sessionTools(added, owner).map(({ load, ...tool }) => ({ + description: sessionHint(added), + tools: sessionTools(added).map(({ load, ...tool }) => ({ type: "function", ...tool, ...(load === "search" ? { deferLoading: true } : {}), diff --git a/src/bundled/goose/runtime.test.ts b/src/bundled/goose/runtime.test.ts index fd7c5a8ac9..9b4d46d563 100644 --- a/src/bundled/goose/runtime.test.ts +++ b/src/bundled/goose/runtime.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import type { BuzzClient, Memories, OwnerAccess } from "../../buzz-mcp/client"; +import type { BuzzClient, Memories } from "../../buzz-mcp/client"; import { callTool, memoryVersion } from "../../buzz-mcp/tools"; import { createAgentMemories } from "../../features/agents/memory"; import { @@ -839,124 +839,98 @@ describe("GooseRuntime", () => { expect(process?.cancelled).toEqual([process?.turns[0]?.sessionId]); }); - it("returns no owner read that the owner's Stop overtook", async () => { - const { agent, deliver, goose } = setup({ hold: true }); + it("returns no read that the owner's Stop overtook", async () => { let release!: () => void; let entered!: () => void; const reading = new Promise((resolve) => { entered = resolve; }); - const channels = vi.fn(async () => { - entered(); - await new Promise((resolve) => { - release = resolve; - }); - return { - execution: { agent: self, owner: alice, community: "relay" }, - channels: [ - { - id: channel, - name: "Owner secrets", - type: "stream", - visibility: "private", - archived: false, - }, - ], - partial: false, - }; + const { deliver, goose } = setup({ + hold: true, + memoryRead: async (_pubkey, _signal, slug) => { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + return { + entries: [ + { slug: slug ?? "core", body: "Read secrets", createdAt: 1 }, + ], + partial: false, + }; + }, }); - const held = vi.fn(); - Object.assign(agent, { ownerAccess: { channels, held } }); const interruptible = vi.fn>(); - await deliver(message("1", "@Gander list my channels"), { interruptible }); + await deliver(message("1", "@Gander read my notes"), { interruptible }); await vi.waitFor(() => expect(goose()?.turns).toHaveLength(1)); const process = goose(); const sessionId = process?.turns[0]?.sessionId; if (!process || !sessionId) throw new Error("Goose turn did not start"); const pending = process.mcp(sessionId, "tools/call", { - name: "owner_channel_list", - arguments: {}, + name: "mem_get", + arguments: { slug: "core" }, }); await reading; await interruptible.mock.calls[0]?.[0](); release(); const reply = await pending; expect(reply?.result).toMatchObject({ isError: true }); - expect(JSON.stringify(reply)).not.toContain("Owner secrets"); + expect(JSON.stringify(reply)).not.toContain("Read secrets"); expect(JSON.stringify(reply)).toContain("turn has ended"); - expect(channels).toHaveBeenCalledOnce(); }); it("answers a watch's or timer's Buzz calls while it runs, and none once it is stopped", async () => { - const f = setup({ - hold: true, - memoryRead: async (_pubkey, _signal, slug) => ({ - entries: [{ slug: slug ?? "core", body: "core notes", createdAt: 1 }], - partial: false, - }), - }); - const listing = { - execution: { agent: self, owner: alice, community: "relay" }, - channels: [ - { - id: channel, - name: "Owner secrets", - type: "stream" as const, - visibility: "private" as const, - archived: false, - }, - ], - partial: false, - }; let release: (() => void) | undefined; let entered!: () => void; const reading = new Promise((resolve) => { entered = resolve; }); - const channels = vi.fn(async () => { - if (channels.mock.calls.length > 1) { - entered(); - await new Promise((resolve) => { - release = resolve; - }); - } - return listing; - }); - Object.assign(f.agent, { - ownerAccess: { channels, held: vi.fn() }, + const f = setup({ + hold: true, + memoryRead: async (_pubkey, _signal, slug) => { + if (slug === "mem/later") { + entered(); + await new Promise((resolve) => { + release = resolve; + }); + } + return { + entries: [ + { slug: slug ?? "core", body: `${slug} notes`, createdAt: 1 }, + ], + partial: false, + }; + }, }); const stop = new AbortController(); const done = f.runtime.runOnce({ delivery: { agent: f.agent, channelId: channel, config: DEFAULT_CONFIG }, key: "timer/daily", signal: stop.signal, - prompt: async () => "Check my channels.", + prompt: async () => "Check my notes.", } as unknown as Parameters[0]); try { await vi.waitFor(() => expect(f.goose()?.turns).toHaveLength(1)); const process = f.goose(); const sessionId = process?.turns[0]?.sessionId; if (!process || !sessionId) throw new Error("Goose turn did not start"); - const call = (name: string) => + const call = (slug: string) => process.mcp(sessionId, "tools/call", { - name, - arguments: name === "mem_get" ? { slug: "core" } : {}, + name: "mem_get", + arguments: { slug }, }); - const memory = await call("mem_get"); + const memory = await call("core"); expect(memory?.result).not.toMatchObject({ isError: true }); expect(JSON.stringify(memory)).toContain("core notes"); - const owned = await call("owner_channel_list"); - expect(owned?.result).not.toMatchObject({ isError: true }); - expect(JSON.stringify(owned)).toContain("Owner secrets"); - const pending = call("owner_channel_list"); + const pending = call("later"); await reading; stop.abort(new Error("stopped")); await done; release?.(); const refused = await pending; expect(refused?.result).toMatchObject({ isError: true }); - expect(JSON.stringify(refused)).not.toContain("Owner secrets"); + expect(JSON.stringify(refused)).not.toContain("later notes"); expect(JSON.stringify(refused)).toContain("turn has ended"); } finally { release?.(); diff --git a/src/bundled/pi/runtime.ts b/src/bundled/pi/runtime.ts index e5c2ac347d..7cd99b8e20 100644 --- a/src/bundled/pi/runtime.ts +++ b/src/bundled/pi/runtime.ts @@ -1142,8 +1142,8 @@ export class PiRuntime { agent, // Whichever host runs the agent fills in its tools' credential. ...(handle && { as: { pubkey: handle.pubkey, relay: handle.relay } }), - tools: sessionTools(this.added(), !!handle?.ownerAccess), - hint: sessionHint(this.added(), !!handle?.ownerAccess), + tools: sessionTools(this.added()), + hint: sessionHint(this.added()), }); } diff --git a/src/buzz-mcp/AGENTS.md b/src/buzz-mcp/AGENTS.md index add3e298e6..c29976db18 100644 --- a/src/buzz-mcp/AGENTS.md +++ b/src/buzz-mcp/AGENTS.md @@ -19,11 +19,6 @@ deliberately. - `settings.ts`: the settings tools (`settings_list`, `settings_get`, `settings_set`). The settings themselves are the host's catalog (`src/app/device-settings.ts`); the tools never name one. -- `owner.ts`: the owner tools (`owner_channel_list`, `owner_channel_get`, - `owner_member_list`, `owner_read`, `owner_canvas_get`, `owner_canvas_history`), - which read with the owner's access where the host gives - the client `ownerAccess`; `src/features/agents/owner-access.ts` owns the - reads and their binding. Plugins' clients never have it. - `session-tools.ts`: what a session lists, each tool's `load` (`always` or `search`), and the server instructions that cover the whole tool set. - `prompt.md`: the posting conventions every agent gets after the shared base diff --git a/src/buzz-mcp/app-client.test.ts b/src/buzz-mcp/app-client.test.ts index 548b401407..cc48088493 100644 --- a/src/buzz-mcp/app-client.test.ts +++ b/src/buzz-mcp/app-client.test.ts @@ -375,83 +375,23 @@ it("offers the handle's settings, checking the turn before each call", () => { expect(settings.get).not.toHaveBeenCalled(); }); -it("offers the handle's owner reads with the turn's signal, withholding answers once the turn ends", async () => { - const memories = async () => ({ entries: [], partial: false }); - expect( - appClient({ pubkey: "agent" } as unknown as AgentHandle, { - read: vi.fn(), - memories, - }).ownerAccess, - ).toBeUndefined(); - const held = gate(); - const answer = { execution: {}, channels: [], partial: false } as never; - const ownerAccess = { - channels: vi.fn(async () => answer), - channel: vi.fn(async () => { - await held.promise; - return answer; - }), - members: vi.fn(async () => answer), - messages: vi.fn(async () => answer), - canvas: vi.fn(async () => answer), - canvasHistory: vi.fn(async () => answer), - held: vi.fn(), - }; - let ended = false; - const controller = new AbortController(); - const client = appClient( - { pubkey: "agent", ownerAccess } as unknown as AgentHandle, - { - read: vi.fn(), - memories, - signal: controller.signal, - check: () => { - if (ended) throw new Error("The turn has ended"); - }, - }, - ); - await expect(client.ownerAccess?.channels()).resolves.toBe(answer); - expect(ownerAccess.channels).toHaveBeenCalledExactlyOnceWith( - controller.signal, +it("never passes on an owner-read capability, even from a handle that carries one", () => { + const read = vi.fn(); + const stale = Object.fromEntries( + [ + "channels", + "channel", + "members", + "messages", + "canvas", + "canvasHistory", + "held", + ].map((name) => [name, read]), ); - await client.ownerAccess?.members("c", { offset: 2, limit: 5 }); - expect(ownerAccess.members).toHaveBeenCalledExactlyOnceWith( - "c", - { offset: 2, limit: 5 }, - controller.signal, - ); - // Each read runs with the turn's signal. - await client.ownerAccess?.messages({ channel: "c", limit: 5 }); - expect(ownerAccess.messages).toHaveBeenCalledExactlyOnceWith( - { channel: "c", limit: 5 }, - controller.signal, - ); - await client.ownerAccess?.canvas("c", "r"); - expect(ownerAccess.canvas).toHaveBeenCalledExactlyOnceWith( - "c", - "r", - controller.signal, - ); - await client.ownerAccess?.canvasHistory("c", { before: 1 }); - expect(ownerAccess.canvasHistory).toHaveBeenCalledExactlyOnceWith( - "c", - { before: 1 }, - controller.signal, + const client = appClient( + { pubkey: "agent", ownerAccess: stale } as unknown as AgentHandle, + { read: vi.fn(), memories: async () => ({ entries: [], partial: false }) }, ); - const execution = { agent: "agent", owner: "owner", community: "c" }; - client.ownerAccess?.held(execution); - expect(ownerAccess.held).toHaveBeenCalledExactlyOnceWith(execution); - const pending = client.ownerAccess?.channel("c"); - ended = true; - held.resolve(); - await expect(pending).rejects.toThrow("ended"); - await expect(client.ownerAccess?.channels()).rejects.toThrow("ended"); - await expect( - client.ownerAccess?.messages({ channel: "c", limit: 5 }), - ).rejects.toThrow("ended"); - expect(ownerAccess.channels).toHaveBeenCalledOnce(); - expect(ownerAccess.messages).toHaveBeenCalledOnce(); - // An answer read before the turn ended is not released after it. - expect(() => client.ownerAccess?.held(execution)).toThrow("ended"); - expect(ownerAccess.held).toHaveBeenCalledOnce(); + expect("ownerAccess" in client).toBe(false); + expect(read).not.toHaveBeenCalled(); }); diff --git a/src/buzz-mcp/app-client.ts b/src/buzz-mcp/app-client.ts index e400bd6938..32e616301b 100644 --- a/src/buzz-mcp/app-client.ts +++ b/src/buzz-mcp/app-client.ts @@ -47,14 +47,7 @@ export function appClient( }; const handoff = agent.handoff; const plugins = agent.plugins; - const { settings, ownerAccess } = agent; - /** An owner read, only while the turn still holds before and after it. */ - const owned = async (read: (signal?: AbortSignal) => Promise) => { - check(); - const result = await read(options.signal); - check(); - return result; - }; + const { settings } = agent; return { ...(agent.protection ? { @@ -90,24 +83,6 @@ export function appClient( pubkey: agent.pubkey, key: agent.key, owner: agent.owner, - ...(ownerAccess && { - ownerAccess: { - channels: () => owned((signal) => ownerAccess.channels(signal)), - channel: (id) => owned((signal) => ownerAccess.channel(id, signal)), - members: (id, page) => - owned((signal) => ownerAccess.members(id, page, signal)), - messages: (request) => - owned((signal) => ownerAccess.messages(request, signal)), - canvas: (channel, revision) => - owned((signal) => ownerAccess.canvas(channel, revision, signal)), - canvasHistory: (channel, cursor) => - owned((signal) => ownerAccess.canvasHistory(channel, cursor, signal)), - held: (execution) => { - check(); - ownerAccess.held(execution); - }, - }, - }), relayAuthor: options.relayAuthor, signingAuthority: options.signingAuthority, attention: agent.attention && { diff --git a/src/buzz-mcp/client.ts b/src/buzz-mcp/client.ts index 4d96930f86..834cd5d879 100644 --- a/src/buzz-mcp/client.ts +++ b/src/buzz-mcp/client.ts @@ -84,146 +84,6 @@ export type Memory = Readonly<{ createdAt: number; }>; -/** Who an owner tool ran as: the calling agent, the human whose access it - * used, and the agent's own community. The host supplies all three. */ -export type OwnerExecution = Readonly<{ - agent: string; - owner: string; - community: string; -}>; -export type OwnerRole = "owner" | "admin" | "member" | "guest" | "bot"; -/** One of the owner's channels, as their session lists it. */ -export type OwnerChannel = Readonly<{ - id: string; - name: string; - type: "stream" | "forum" | "dm" | "solo" | "unknown"; - visibility: "open" | "private" | "unknown"; - archived: boolean; - /** The owner's role from the relay-signed roster; `null` when they are not - * a member, absent when unknown. */ - role?: OwnerRole | "unknown" | null; - /** Membership restored from this device, not yet confirmed by the relay. */ - cached?: true; - members?: number; -}>; -export type OwnerChannelList = Readonly<{ - execution: OwnerExecution; - channels: readonly OwnerChannel[]; - /** The owner's channel list is still loading, failed, or hit its cap: - * missing channels are not evidence that none exist. */ - partial: boolean; - error?: string; -}>; -export type OwnerChannelDetails = Readonly<{ - execution: OwnerExecution; - channel: OwnerChannel & - Readonly<{ - description?: string; - joinRole?: "member" | "guest"; - ttlSeconds?: number; - creator?: string; - createdAt?: number; - }>; - /** From the relay-signed admin list and roster, read for this call. The - * relay still authorizes every command. */ - capabilities: Readonly<{ - manageRoles: boolean; - removeMembers: boolean; - archive: boolean; - unarchive: boolean; - delete: boolean; - }>; -}>; -export type OwnerMemberPage = Readonly<{ - execution: OwnerExecution; - channel: string; - total: number; - members: readonly Readonly<{ - pubkey: string; - role: OwnerRole | "unknown"; - name?: string; - }>[]; -}>; -/** A message read: a thread (any of its messages' IDs) in its own channel, a - * search (`search`, optionally in `channel`), or else `channel`'s latest. - * `author` and `since` narrow a search or channel read; `before` and - * `beforeId` are the relay's cursor. */ -export type OwnerReadRequest = Readonly<{ - channel?: string; - thread?: string; - search?: string; - author?: string; - since?: number; - before?: number; - beforeId?: string; - limit: number; -}>; -export type OwnerMessagePage = Readonly<{ - execution: OwnerExecution; - /** The channel read: a thread's own, or the one a search was narrowed to. */ - channel?: string; - root?: string; - /** The messages, and the edits to them, in any order. A thread's root - * comes on its first page only. */ - events: readonly BuzzEvent[]; - /** Authors' display names, where they have one. */ - names: Readonly>; - /** Authors' owners, where the owner's Buzz has verified one; their edits - * in the channel count as the author's. */ - owners?: Readonly>; - /** Where the next older page starts, when older messages remain. */ - more?: Readonly<{ before: number; beforeId: string }>; - /** A search filled its limit, so more matches may exist. */ - limited?: true; - /** The edit lookup filled its limit, so some messages may show older text. */ - editsLimited?: true; - /** The owner's channel list is incomplete, so a search may be missing - * matches from channels not yet listed. */ - partial?: true; -}>; -export type OwnerCanvas = Readonly<{ - execution: OwnerExecution; - channel: string; - /** The current canvas, or the revision asked for; none when it has none. */ - canvas?: BuzzEvent; -}>; -export type OwnerCanvasHistory = Readonly<{ - execution: OwnerExecution; - channel: string; - /** A page of revisions, newest first. */ - revisions: readonly BuzzEvent[]; -}>; -/** Read-only operations the app runs with its signed-in human's access, for - * that human's own agent in that agent's community. Typed operations only: - * no filters, signing or client of the owner's. */ -export type OwnerAccess = Readonly<{ - channels(signal?: AbortSignal): Promise; - channel(id: string, signal?: AbortSignal): Promise; - members( - id: string, - page: Readonly<{ offset: number; limit: number }>, - signal?: AbortSignal, - ): Promise; - messages( - request: OwnerReadRequest, - signal?: AbortSignal, - ): Promise; - /** The current canvas, or its revision `revision`. */ - canvas( - channel: string, - revision?: string, - signal?: AbortSignal, - ): Promise; - canvasHistory( - channel: string, - cursor: Readonly<{ before?: number; beforeId?: string }>, - signal?: AbortSignal, - ): Promise; - /** Throws unless the binding that answered with `execution` still holds, - * as checked just before its answer is released; reads nothing. */ - held(execution: OwnerExecution): void; -}>; - export interface BuzzClient { protect?(action: ProtectionAction): Promise; /** The runtime reviews each tool call itself before Buzz dispatches it. */ @@ -272,10 +132,6 @@ export interface BuzzClient { ): void; /** The agent's owner, as its host records it. */ readonly owner?: string | undefined; - /** Reads with the owner's access, only in the owner's app-hosted run of - * the agent by a bundled harness where the build enables it. Never given to - * plugins, including installed harness plugins. */ - readonly ownerAccess?: OwnerAccess; /** Trusted connection authorities, never inferred from query results. */ readonly relayAuthor?: string | undefined; readonly signingAuthority?: string | undefined; diff --git a/src/buzz-mcp/owner.test.ts b/src/buzz-mcp/owner.test.ts deleted file mode 100644 index 74383cebf0..0000000000 --- a/src/buzz-mcp/owner.test.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { ProtectionAction } from "../features/agent-protection/service"; -import type { - BuzzClient, - BuzzEvent, - OwnerAccess, - OwnerChannel, -} from "./client"; -import { CANVAS_HISTORY_PAGE } from "../features/channel-templates/canvas-limits"; -import { OWNER_TOOLS, callOwnerTool, isOwnerTool } from "./owner"; -import { respond } from "./rpc"; -import { - type AddedTool, - callSessionTool, - sessionHint, - sessionTools, -} from "./session-tools"; - -const executedAs = { - agent: "b".repeat(64), - owner: "a".repeat(64), - community: "https://relay.example.test", -}; -const id = "11111111-1111-4111-8111-111111111111"; -const hex = (c: string) => c.repeat(64); -const person = hex("c"); -const message = (key: string, patch: Partial = {}): BuzzEvent => ({ - id: hex(key), - pubkey: person, - created_at: 1_700_000_000, - kind: 9, - tags: [["h", id]], - content: `message ${key}`, - sig: "", - ...patch, -}); -const channel = (name: string): OwnerChannel => ({ - id: `${name}-id`, - name, - type: "stream", - visibility: "private", - archived: false, - role: null, -}); - -function owned() { - return { - channels: vi.fn(async () => ({ - execution: executedAs, - channels: ["Plans", "Planning", "Lunch"].map(channel), - partial: false, - })), - channel: vi.fn(async () => ({ - execution: executedAs, - channel: channel("Plans"), - capabilities: { - manageRoles: false, - removeMembers: false, - archive: false, - unarchive: false, - delete: false, - }, - })), - members: vi.fn(async () => ({ - execution: executedAs, - channel: id, - total: 3, - members: [{ pubkey: executedAs.owner, role: "owner" as const }], - })), - messages: vi.fn(async () => ({ - execution: executedAs, - channel: id, - events: [message("1"), message("2")], - names: { [person]: "Pat" }, - })), - canvas: vi.fn(async () => ({ - execution: executedAs, - channel: id, - canvas: message("9", { kind: 40100, content: "# Plan\nShip it" }), - })), - canvasHistory: vi.fn(async () => ({ - execution: executedAs, - channel: id, - revisions: [message("9", { kind: 40100, content: "# Plan\nShip it" })], - })), - held: vi.fn(), - }; -} -const clientWith = (fields: Partial = {}) => - ({ - pubkey: executedAs.agent, - key: executedAs.agent, - ...fields, - }) as BuzzClient; - -describe("callOwnerTool", () => { - it("lists, filters and pages the owner's channels with who it ran as", async () => { - const ownerAccess = owned(); - const client = clientWith({ ownerAccess }); - expect( - JSON.parse( - ( - await callOwnerTool(client, "owner_channel_list", { - q: "PLAN", - limit: 1, - }) - ).text, - ), - ).toEqual({ - executedAs, - channels: [channel("Plans")], - total: 2, - next: { offset: 1 }, - partial: false, - }); - ownerAccess.channels.mockResolvedValueOnce({ - execution: executedAs, - channels: [], - partial: true, - error: "offline", - }); - expect( - JSON.parse( - String((await callOwnerTool(client, "owner_channel_list", {})).text), - ), - ).toEqual({ - executedAs, - channels: [], - total: 0, - partial: true, - error: "offline", - }); - }); - - it("answers a channel and a page of its members", async () => { - const ownerAccess = owned(); - const client = clientWith({ ownerAccess }); - expect( - JSON.parse( - (await callOwnerTool(client, "owner_channel_get", { channel: id })) - .text, - ), - ).toMatchObject({ executedAs, channel: { name: "Plans" } }); - expect(ownerAccess.channel).toHaveBeenCalledWith(id); - expect( - JSON.parse( - ( - await callOwnerTool(client, "owner_member_list", { - channel: id, - offset: 1, - limit: 1, - }) - ).text, - ), - ).toEqual({ - executedAs, - channel: id, - total: 3, - members: [{ pubkey: executedAs.owner, role: "owner" }], - next: { offset: 2 }, - }); - expect(ownerAccess.members).toHaveBeenCalledWith(id, { - offset: 1, - limit: 1, - }); - await callOwnerTool(client, "owner_member_list", { - channel: id, - limit: 900, - }); - expect(ownerAccess.members).toHaveBeenLastCalledWith(id, { - offset: 0, - limit: 500, - }); - }); - - it.each([ - ["owner_channel_get", {}, "channel is required"], - ["owner_channel_get", { channel: 7 }, "channel must be text"], - ["owner_member_list", { channel: id, limit: 0 }, "limit must be"], - ["owner_member_list", { channel: id, offset: -1 }, "offset must be"], - ["owner_channel_list", { limit: 1.5 }, "limit must be"], - // No argument may name another owner, account, community or agent. - ["owner_channel_list", { owner: executedAs.agent }, "owner cannot be used"], - ["owner_channel_get", { channel: id, community: "x" }, "cannot be used"], - // No default to the current conversation. - ["owner_read", {}, "channel, thread, message_url or q is required"], - ["owner_read", { thread: "abc" }, "thread must be a 64-character"], - ["owner_read", { channel: id, before_id: hex("1") }, "before_id needs"], - [ - "owner_read", - { q: "x", before: 5, before_id: hex("1") }, - "before_id pages", - ], - ["owner_read", { thread: hex("1"), author: person }, "not a thread"], - ["owner_read", { q: "x", thread: hex("1") }, "q searches channels"], - ["owner_read", { channel: id, since: 9, before: 5 }, "since must not"], - ["owner_read", { channel: id, author: "pat" }, "author must be"], - ["owner_read", { channel: id, message_url: "x" }, "cannot be combined"], - ["owner_read", { message_url: "https://x.test" }, "Buzz message link"], - ["owner_read", { channel: id, as: executedAs.owner }, "cannot be used"], - ["owner_canvas_get", { channel: id, id: "v1" }, "id must be a 64"], - ["owner_canvas_history", { channel: id, before_id: hex("1") }, "needs"], - ["owner_canvas_history", {}, "channel is required"], - ])("rejects %s %j before reading", async (name, args, message) => { - const ownerAccess = owned(); - await expect( - callOwnerTool(clientWith({ ownerAccess }), name, args), - ).rejects.toThrow(message); - const { held: _, ...reads } = ownerAccess; - for (const read of Object.values(reads)) - expect(read).not.toHaveBeenCalled(); - }); - - it("reads nothing for a client without owner access", async () => { - for (const { name } of OWNER_TOOLS) - await expect( - callOwnerTool(clientWith(), name, { channel: id }), - ).rejects.toThrow("not available to this agent here; nothing was read"); - }); - - it("reads a channel, a thread or a search with read's limits and continuation", async () => { - const ownerAccess = owned(); - const client = clientWith({ ownerAccess }); - const read = async (args: Record) => - JSON.parse((await callOwnerTool(client, "owner_read", args)).text); - ownerAccess.messages.mockResolvedValueOnce({ - execution: executedAs, - channel: id, - // Out of order, with edits by their author and a newer one by someone else. - events: [ - message("2", { created_at: 1_700_000_100 }), - message("5", { - kind: 40003, - created_at: 1_700_000_250, - tags: [ - ["h", id], - ["e", hex("2")], - ], - content: "their edit", - }), - message("1"), - message("3", { - kind: 40003, - created_at: 1_700_000_200, - tags: [ - ["h", id], - ["e", hex("1")], - ], - content: "edited", - }), - message("4", { - kind: 40003, - pubkey: executedAs.owner, - created_at: 1_700_000_300, - tags: [ - ["h", id], - ["e", hex("2")], - ], - content: "not theirs", - }), - ], - names: { [person]: "Pat" }, - more: { before: 1_700_000_000, beforeId: hex("1") }, - }); - expect( - await read({ channel: id, author: person, since: 5, limit: 999 }), - ).toEqual({ - executedAs, - channel: id, - messages: [ - `${hex("1")} from ${person} "Pat" at 2023-11-14T22:13Z: edited`, - `${hex("2")} from ${person} "Pat" at 2023-11-14T22:15Z: their edit`, - ], - next: { - channel: id, - author: person, - since: 5, - limit: 200, - before: 1_700_000_000, - before_id: hex("1"), - }, - }); - expect(ownerAccess.messages).toHaveBeenLastCalledWith({ - channel: id, - author: person, - since: 5, - limit: 200, - }); - - // Its verified owner's correction counts in its channel, newest first. - const correction = (key: string, h: string, created_at: number) => - message(key, { - kind: 40003, - pubkey: executedAs.owner, - created_at, - tags: [ - ["h", h], - ["e", hex("2")], - ], - content: `owner ${key}`, - }); - ownerAccess.messages.mockResolvedValueOnce({ - execution: executedAs, - channel: id, - events: [ - message("2"), - message("5", { - kind: 40003, - created_at: 1_700_000_100, - tags: [ - ["h", id], - ["e", hex("2")], - ], - content: "their edit", - }), - correction("6", id, 1_700_000_200), - correction("7", "elsewhere", 1_700_000_300), - ], - names: {}, - owners: { [person]: executedAs.owner }, - }); - expect((await read({ channel: id })).messages).toEqual([ - `${hex("2")} from ${person} at 2023-11-14T22:13Z: owner 6`, - ]); - - ownerAccess.messages.mockResolvedValueOnce({ - execution: executedAs, - channel: id, - root: hex("1"), - events: [message("1")], - names: {}, - more: { before: 7, beforeId: hex("1") }, - }); - expect( - await read({ thread: hex("2"), before: 9, before_id: hex("5") }), - ).toMatchObject({ - thread: hex("1"), - next: { thread: hex("1"), limit: 100, before: 7, before_id: hex("1") }, - }); - expect(ownerAccess.messages).toHaveBeenLastCalledWith({ - thread: hex("2"), - before: 9, - beforeId: hex("5"), - limit: 100, - }); - - // A message link is a thread read, from any of its messages. - await read({ - message_url: `buzz://message?channel=${id}&id=${hex("2")}`, - }); - expect(ownerAccess.messages).toHaveBeenLastCalledWith({ - thread: hex("2"), - limit: 100, - }); - - // The relay caps a read at 500, and the thread read asks one more. - await read({ thread: hex("2"), limit: 900 }); - expect(ownerAccess.messages).toHaveBeenLastCalledWith({ - thread: hex("2"), - limit: 499, - }); - - ownerAccess.messages.mockResolvedValueOnce({ - execution: executedAs, - events: [], - names: {}, - limited: true, - editsLimited: true, - partial: true, - }); - expect(await read({ q: " plan ", limit: 500 })).toEqual({ - executedAs, - messages: [], - limited: true, - edits_limited: true, - partial: true, - }); - expect(ownerAccess.messages).toHaveBeenLastCalledWith({ - search: "plan", - limit: 100, - }); - }); - - it("reads a canvas, an earlier revision and a page of its history", async () => { - const ownerAccess = owned(); - const client = clientWith({ ownerAccess }); - const call = async (name: string, args: Record) => - JSON.parse((await callOwnerTool(client, name, args)).text); - expect(await call("owner_canvas_get", { channel: id })).toEqual({ - executedAs, - channel: id, - id: hex("9"), - author: person, - at: "2023-11-14T22:13Z", - canvas: "# Plan\nShip it", - }); - expect(ownerAccess.canvas).toHaveBeenLastCalledWith(id, undefined); - await call("owner_canvas_get", { channel: id, id: hex("8") }); - expect(ownerAccess.canvas).toHaveBeenLastCalledWith(id, hex("8")); - ownerAccess.canvas.mockResolvedValueOnce({ - execution: executedAs, - channel: id, - }); - expect(await call("owner_canvas_get", { channel: id })).toEqual({ - executedAs, - channel: id, - canvas: null, - }); - - expect(await call("owner_canvas_history", { channel: id })).toEqual({ - executedAs, - channel: id, - version: hex("9"), - revisions: [ - { - id: hex("9"), - at: "2023-11-14T22:13Z", - author: person, - bytes: 14, - line: "# Plan", - }, - ], - }); - expect(ownerAccess.canvasHistory).toHaveBeenLastCalledWith(id, {}); - const full = Array.from({ length: CANVAS_HISTORY_PAGE }, (_, at) => - message(String(at % 10), { kind: 40100, created_at: 50 - at }), - ); - ownerAccess.canvasHistory.mockResolvedValueOnce({ - execution: executedAs, - channel: id, - revisions: full, - }); - const older = await call("owner_canvas_history", { - channel: id, - before: 60, - before_id: hex("a"), - }); - expect(older).not.toHaveProperty("version"); - expect(older.next).toEqual({ - channel: id, - before: full.at(-1)?.created_at, - before_id: full.at(-1)?.id, - }); - expect(ownerAccess.canvasHistory).toHaveBeenLastCalledWith(id, { - before: 60, - beforeId: hex("a"), - }); - }); - - it("names only the read tools", () => { - expect(OWNER_TOOLS.map(({ name }) => name)).toEqual([ - "owner_channel_list", - "owner_channel_get", - "owner_member_list", - "owner_read", - "owner_canvas_get", - "owner_canvas_history", - ]); - expect( - OWNER_TOOLS.every( - (tool) => - tool.annotations?.readOnlyHint === true && - tool.inputSchema.additionalProperties === false, - ), - ).toBe(true); - expect(isOwnerTool("owner_channel_list")).toBe(true); - expect(isOwnerTool("owner_send")).toBe(false); - }); -}); - -describe("owner tools in the session", () => { - const names = (tools: readonly { name: string }[]) => - tools.map(({ name }) => name); - - it("lists them only to a client with owner access, on search", async () => { - expect(names(sessionTools()).some(isOwnerTool)).toBe(false); - expect(sessionHint()).not.toContain("owner_channel_list"); - const tools = sessionTools([], true); - expect( - tools.filter((tool) => isOwnerTool(tool.name)).map((tool) => tool.load), - ).toEqual(OWNER_TOOLS.map(() => "search")); - expect(sessionHint([], true)).toContain("owner_channel_list"); - const list = async (client?: BuzzClient) => - ( - (await respond(client, {}, { id: 1, method: "tools/list" })) as { - result: { tools: { name: string }[] }; - } - ).result.tools; - expect(names(await list()).some(isOwnerTool)).toBe(false); - expect(names(await list(clientWith())).some(isOwnerTool)).toBe(false); - expect(names(await list(clientWith({ ownerAccess: owned() })))).toEqual( - expect.arrayContaining(names(OWNER_TOOLS)), - ); - }); - - it("refuses a direct or remembered call from a client without owner access", async () => { - await expect( - callSessionTool(clientWith(), {}, "owner_channel_list", {}), - ).rejects.toThrow("nothing was read"); - }); - - it("gives a plugin tool neither owner access nor settings, even one shadowing an owner tool", async () => { - const ownerAccess = owned(); - const settings = { list: vi.fn(), get: vi.fn(), set: vi.fn() }; - const seen: BuzzClient[] = []; - const plugin = (name: string): AddedTool => ({ - name, - description: "A plugin's tool.", - inputSchema: { type: "object" }, - call: async (client) => { - seen.push(client); - return "plugin"; - }, - }); - const client = clientWith({ ownerAccess, settings }); - for (const name of ["notes_add", "owner_channel_list"]) - expect(await callSessionTool(client, {}, name, {}, [plugin(name)])).toBe( - "plugin", - ); - expect(seen).toHaveLength(2); - for (const each of seen) { - expect("ownerAccess" in each).toBe(false); - expect("settings" in each).toBe(false); - expect(each.pubkey).toBe(executedAs.agent); - } - expect(ownerAccess.channels).not.toHaveBeenCalled(); - }); - - it("releases an owner answer only after its review, while its binding holds", async () => { - const ownerAccess = owned(); - const order: string[] = []; - const protect = vi.fn(async (action: ProtectionAction) => { - order.push(action.kind); - }); - ownerAccess.held.mockImplementation(() => { - order.push("held"); - }); - const client = clientWith({ ownerAccess, protect }); - expect( - JSON.parse( - String(await callSessionTool(client, {}, "owner_channel_list", {})), - ), - ).toMatchObject({ executedAs }); - expect(order).toEqual(["tool", "tool-result", "held"]); - expect(ownerAccess.held).toHaveBeenCalledWith(executedAs); - ownerAccess.held.mockImplementation(() => { - throw new Error("changed during the call"); - }); - await expect( - callSessionTool(client, {}, "owner_channel_list", {}), - ).rejects.toThrow("changed during the call"); - }); - - it("reads nothing when protection refuses the call, and withholds a refused result", async () => { - const ownerAccess = owned(); - const protect = vi.fn(async (action: ProtectionAction) => { - if (action.kind === "tool") throw new Error("denied action"); - }); - await expect( - callSessionTool( - clientWith({ ownerAccess, protect }), - {}, - "owner_channel_list", - {}, - ), - ).rejects.toThrow("denied action"); - expect(ownerAccess.channels).not.toHaveBeenCalled(); - - protect.mockImplementation(async (action) => { - if (action.kind === "tool-result") throw new Error("denied result"); - }); - await expect( - callSessionTool( - clientWith({ ownerAccess, protect }), - {}, - "owner_channel_list", - {}, - ), - ).rejects.toThrow("denied result"); - expect(ownerAccess.channels).toHaveBeenCalledOnce(); - const result = protect.mock.calls.at(-1)?.[0]; - expect(result).toMatchObject({ kind: "tool-result" }); - expect(String(result?.content)).toContain('"executedAs"'); - }); -}); diff --git a/src/buzz-mcp/owner.ts b/src/buzz-mcp/owner.ts deleted file mode 100644 index 7035c3caa0..0000000000 --- a/src/buzz-mcp/owner.ts +++ /dev/null @@ -1,385 +0,0 @@ -// The owner tools (`owner_*`): read with the signed-in human's access instead -// of the agent's, only where the host gives the client `ownerAccess`. The host -// decides who the owner, agent and community are; no argument names them. -import { CANVAS_HISTORY_PAGE } from "../features/channel-templates/canvas-limits"; -import { parseBuzzLink } from "../features/navigation/buzz-links"; -import type { - BuzzClient, - OwnerAccess, - OwnerExecution, - OwnerReadRequest, -} from "./client"; -import { - bound, - edited, - HEX, - messageLine, - pubkey, - type Tool, - time, - unixTime, -} from "./tools"; - -const channel = { - type: "string", - description: "Channel ID, from owner_channel_list.", -} as const; -const limit = (max: number, fallback: number) => - ({ - type: "integer", - description: `Default ${fallback}, max ${max}.`, - }) as const; -const offset = { - type: "integer", - description: "Entries to skip, from a previous answer's next.", -} as const; -const before = { - type: "integer", - description: "Unix seconds, at or before.", -} as const; -const beforeId = { - type: "string", - description: "For paginating: with before, from a previous answer's next.", -} as const; -const tool = ( - name: string, - description: string, - properties: Readonly>, - required: readonly string[] = [], -): Tool => ({ - name, - description, - inputSchema: { - type: "object", - properties, - ...(required.length ? { required } : {}), - additionalProperties: false, - }, - annotations: { readOnlyHint: true }, -}); - -export const OWNER_TOOLS: readonly Tool[] = [ - tool( - "owner_channel_list", - "Your owner's channels in this community, read with their access, including ones you are not in and archived ones. partial means some may be missing.", - { q: { type: "string" }, limit: limit(500, 100), offset }, - ), - tool( - "owner_channel_get", - "One of your owner's channels, read with their access: its details, their role and what they may manage.", - { channel }, - ["channel"], - ), - tool( - "owner_member_list", - "A channel's members and roles, read with your owner's access.", - { channel, limit: limit(500, 100), offset }, - ["channel"], - ), - tool( - "owner_read", - "Read a channel's latest messages, a whole thread, or search with q, with your owner's access, including channels you are not in. Marks nothing read. edits_limited means some messages show earlier text.", - { - channel, - thread: { - type: "string", - description: "Any message ID in the thread.", - }, - message_url: { - type: "string", - description: "Buzz message link; replaces channel and thread.", - }, - q: { - type: "string", - description: - "Search text. Without channel, other channels' matches can fill a page (limited) and are dropped; then narrow with channel, before or since.", - }, - author: { - type: "string", - description: "With q or channel: only this hex pubkey or npub.", - }, - since: { - type: "integer", - description: "With q or channel: Unix seconds, at or after.", - }, - before, - before_id: beforeId, - limit: { - type: "integer", - description: "Search 20 (max 100), thread 100 (499), channel 30 (200).", - }, - }, - ), - tool( - "owner_canvas_get", - "A channel's canvas, or an earlier revision of it, read with your owner's access.", - { - channel, - id: { - type: "string", - description: "A revision ID, from owner_canvas_history.", - }, - }, - ["channel"], - ), - tool( - "owner_canvas_history", - "A channel's canvas revisions, newest first, read with your owner's access: each one's id, time, author, size and first line.", - { channel, before, before_id: beforeId }, - ["channel"], - ), -]; - -export const isOwnerTool = (name: string) => - OWNER_TOOLS.some((tool) => tool.name === name); - -/** Runs owner tool `name`. Throws, before reading anything, when this - * client has no owner access, as a plugin's or another host's has not. */ -export async function callOwnerTool( - client: BuzzClient, - name: string, - args: Readonly>, -): Promise> { - const tool = OWNER_TOOLS.find((tool) => tool.name === name); - if (!tool) throw new Error(`No tool ${name}`); - const { ownerAccess } = client; - if (!ownerAccess) - throw new Error( - "Owner tools are not available to this agent here; nothing was read.", - ); - const extra = Object.keys(args).filter( - (arg) => !Object.hasOwn(tool.inputSchema.properties, arg), - ); - if (extra.length) - throw new Error(`${extra.join(", ")} cannot be used with ${name}.`); - if (name === "owner_channel_list") { - const page = paging(args); - const q = optionalText(args.q, "q")?.toLowerCase(); - const list = await ownerAccess.channels(); - const matched = q - ? list.channels.filter((item) => item.name.toLowerCase().includes(q)) - : list.channels; - const shown = matched.slice(page.offset, page.offset + page.limit); - const next = page.offset + shown.length; - return answer(ownerAccess, list.execution, { - channels: shown, - total: matched.length, - ...(next < matched.length ? { next: { offset: next } } : {}), - partial: list.partial, - ...(list.error ? { error: list.error } : {}), - }); - } - if (name === "owner_read") return read(ownerAccess, args); - const id = optionalText(args.channel, "channel"); - if (!id) throw new Error("channel is required."); - if (name === "owner_canvas_get") { - const revision = eventId(args.id, "id"); - const { execution, channel, canvas } = await ownerAccess.canvas( - id, - revision, - ); - return answer(ownerAccess, execution, { - channel, - ...(canvas - ? { - id: canvas.id, - author: canvas.pubkey, - at: time(canvas.created_at), - canvas: canvas.content, - } - : { canvas: null }), - }); - } - if (name === "owner_canvas_history") { - const cursor = pageCursor(args); - const { execution, channel, revisions } = await ownerAccess.canvasHistory( - id, - cursor, - ); - const last = revisions.at(-1); - return answer(ownerAccess, execution, { - channel, - ...(cursor.before === undefined && revisions[0] - ? { version: revisions[0].id } - : {}), - revisions: revisions.map((revision) => ({ - id: revision.id, - at: time(revision.created_at), - author: revision.pubkey, - bytes: new TextEncoder().encode(revision.content).length, - line: (revision.content.trim().split("\n", 1)[0] ?? "").slice(0, 80), - })), - ...(last && revisions.length >= CANVAS_HISTORY_PAGE - ? { - next: { channel, before: last.created_at, before_id: last.id }, - } - : {}), - }); - } - if (name === "owner_channel_get") { - const { execution, ...details } = await ownerAccess.channel(id); - return answer(ownerAccess, execution, details); - } - const page = paging(args); - const { execution, ...members } = await ownerAccess.members(id, page); - const next = page.offset + members.members.length; - return answer(ownerAccess, execution, { - ...members, - ...(next < members.total ? { next: { offset: next } } : {}), - }); -} - -/** owner_read: `read`'s arguments, limits and continuation, with no - * default to the current conversation. */ -async function read( - ownerAccess: OwnerAccess, - args: Readonly>, -) { - let channel = optionalText(args.channel, "channel"); - let thread = optionalText(args.thread, "thread"); - if (args.message_url !== undefined) { - if (channel || thread) - throw new Error("message_url cannot be combined with channel or thread."); - const link = - typeof args.message_url === "string" - ? parseBuzzLink(args.message_url) - : null; - if (link?.format !== "legacy" || !link.messageId) - throw new Error("message_url must be a Buzz message link."); - channel = link.channelId; - thread = link.messageId; - } - if (thread !== undefined) thread = eventId(thread, "thread"); - const q = optionalText(args.q, "q"); - const cursor = pageCursor(args); - const since = unixTime(args, "since"); - if ( - since !== undefined && - cursor.before !== undefined && - since > cursor.before - ) - throw new Error("since must not be after before."); - let author: string | undefined; - if (args.author !== undefined) - try { - author = pubkey(args.author as string); - } catch { - throw new Error( - "author must be a hex pubkey or npub; users finds one by name.", - ); - } - const given = typeof args.limit === "number" ? args.limit : undefined; - let request: OwnerReadRequest; - if (q) { - if (thread) - throw new Error( - "q searches channels, not a thread; narrow it with channel.", - ); - if (cursor.beforeId) - throw new Error( - "before_id pages thread and channel reads; narrow a search with before or since.", - ); - request = { - search: q, - ...(channel ? { channel } : {}), - ...(author ? { author } : {}), - ...(since !== undefined ? { since } : {}), - ...(cursor.before !== undefined ? { before: cursor.before } : {}), - limit: bound(given, 20, 100), - }; - } else if (thread) { - if (author || since !== undefined) - throw new Error( - "author and since filter a search (q) or a channel read, not a thread; add q or channel.", - ); - // One under the relay read cap leaves room for the more-pages lookahead. - request = { thread, ...cursor, limit: bound(given, 100, 499) }; - } else if (channel) { - request = { - channel, - ...(author ? { author } : {}), - ...(since !== undefined ? { since } : {}), - ...cursor, - limit: bound(given, 30, 200), - }; - } else throw new Error("channel, thread, message_url or q is required."); - const page = await ownerAccess.messages(request); - const scope = { - ...(page.channel ? { channel: page.channel } : {}), - ...(page.root ? { root: page.root } : {}), - }; - const more = page.root - ? { thread: page.root, limit: request.limit } - : { - channel: page.channel, - ...(author ? { author } : {}), - ...(since !== undefined ? { since } : {}), - limit: request.limit, - }; - return answer(ownerAccess, page.execution, { - ...(page.channel ? { channel: page.channel } : {}), - ...(page.root ? { thread: page.root } : {}), - messages: edited(page.events, (author) => page.owners?.[author]).map( - (event) => messageLine(event, page.names[event.pubkey], scope), - ), - ...(page.more - ? { - next: { - ...more, - before: page.more.before, - before_id: page.more.beforeId, - }, - } - : {}), - ...(page.limited ? { limited: true } : {}), - // Messages whose edits did not fit show their earlier text. - ...(page.editsLimited ? { edits_limited: true } : {}), - ...(page.partial ? { partial: true } : {}), - }); -} - -/** The answer's text, and a check that the binding it was read with still - * holds. */ -function answer( - ownerAccess: OwnerAccess, - execution: OwnerExecution, - fields: object, -) { - return { - text: JSON.stringify({ executedAs: execution, ...fields }), - held: () => ownerAccess.held(execution), - }; -} - -/** A `before` and `before_id` cursor. */ -function pageCursor(args: Readonly>) { - const before = unixTime(args, "before"); - const beforeId = args.before_id; - if (beforeId === undefined) return before === undefined ? {} : { before }; - if (before === undefined) - throw new Error("before_id needs before, from the same next."); - return { before, beforeId: eventId(beforeId, "before_id") as string }; -} - -function eventId(value: unknown, name: string) { - if (value === undefined) return undefined; - if (typeof value !== "string" || !HEX.test(value)) - throw new Error(`${name} must be a 64-character event ID.`); - return value; -} - -function paging(args: Readonly>) { - const limit = args.limit ?? 100; - const offset = args.offset ?? 0; - if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) - throw new Error("limit must be a whole number, 1 or more."); - if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) - throw new Error("offset must be a whole number, 0 or more."); - return { limit: Math.min(limit, 500), offset }; -} - -function optionalText(value: unknown, name: string) { - if (value === undefined) return undefined; - if (typeof value !== "string") throw new Error(`${name} must be text.`); - return value.trim() || undefined; -} diff --git a/src/buzz-mcp/rpc.ts b/src/buzz-mcp/rpc.ts index 34b18c5387..f48498adfb 100644 --- a/src/buzz-mcp/rpc.ts +++ b/src/buzz-mcp/rpc.ts @@ -55,7 +55,7 @@ export async function respond( return result({ // A connection keeps its client, so its list stays the same. One // whose client can't change settings, such as a plugin's, lacks them. - tools: sessionTools(added, !!client?.ownerAccess) + tools: sessionTools(added) .filter( ({ name }) => !client || client.settings || !isSettingsTool(name), ) diff --git a/src/buzz-mcp/session-tools.test.ts b/src/buzz-mcp/session-tools.test.ts index 362e8f014b..3b0ccfba91 100644 --- a/src/buzz-mcp/session-tools.test.ts +++ b/src/buzz-mcp/session-tools.test.ts @@ -28,6 +28,26 @@ it("keys the added tools whatever order plugins started in", () => { ).not.toBe(key); }); +// The retired owner-read tools, listed here rather than taken from a catalog. +const RETIRED_OWNER_TOOLS = [ + "owner_channel_list", + "owner_channel_get", + "owner_member_list", + "owner_read", + "owner_canvas_get", + "owner_canvas_history", +]; + +it("neither lists nor mentions the retired owner-read tools", () => { + const names = sessionTools([tool("notes_add")]).map((each) => each.name); + const hint = sessionHint([tool("notes_add")]); + for (const name of RETIRED_OWNER_TOOLS) { + expect(names).not.toContain(name); + expect(hint).not.toContain(name); + } + expect(hint).not.toMatch(/owner's (own )?access|with your access/); +}); + it("loads the Buzz tools up front, and attention and plugins' tools on search", () => { const tools = sessionTools([tool("notes_add")]); const load = (name: string) => tools.find((each) => each.name === name)?.load; diff --git a/src/buzz-mcp/session-tools.ts b/src/buzz-mcp/session-tools.ts index 4ad1c3d4f2..4e674143a3 100644 --- a/src/buzz-mcp/session-tools.ts +++ b/src/buzz-mcp/session-tools.ts @@ -14,7 +14,6 @@ import { isAttentionTool, } from "./attention"; import type { BuzzClient, Context } from "./client"; -import { callOwnerTool, isOwnerTool, OWNER_TOOLS } from "./owner"; import { callPluginTool, isPluginTool, PLUGIN_TOOLS } from "./plugins"; import { callSettingsTool, isSettingsTool, SETTINGS_TOOLS } from "./settings"; import { @@ -82,33 +81,25 @@ const listed = ({ name, description, inputSchema }: AddedTool): Listed => ({ * never grows what every turn carries. */ export type SessionTool = Listed & Readonly<{ load: "always" | "search" }>; -/** The tools a session lists: the Buzz tools, then those plugins add. The - * owner tools only with `owner`: a client with `ownerAccess`. */ +/** The tools a session lists: the Buzz tools, then those plugins add. */ export const sessionTools = ( added: readonly AddedTool[] = [], - owner = false, ): readonly SessionTool[] => [ ...TOOLS.map((tool) => ({ ...tool, load: "always" as const })), ...ATTENTION_TOOLS.map((tool) => ({ ...tool, load: "search" as const })), ...SOLO_WORKER_TOOLS.map((tool) => ({ ...tool, load: "search" as const })), ...PLUGIN_TOOLS.map((tool) => ({ ...tool, load: "search" as const })), ...SETTINGS_TOOLS.map((tool) => ({ ...tool, load: "search" as const })), - ...(owner - ? OWNER_TOOLS.map((tool) => ({ ...tool, load: "search" as const })) - : []), ...added.map((tool) => ({ ...listed(tool), load: "search" as const })), ]; /** What the server (Claude) or namespace (Codex) with the tools says up * front, so the model knows what it can search for. */ -export function sessionHint(added: readonly AddedTool[] = [], owner = false) { +export function sessionHint(added: readonly AddedTool[] = []) { const plugins = added.length ? ` and tools plugins add (${added.map((tool) => tool.name).join(", ")})` : ""; - const owned = owner - ? ", your owner's channels, members, messages and canvases read with their access (owner_channel_list, owner_read)" - : ""; - return `Read and act in Buzz as this agent. Search for the rest when you need them: your attention (Interests, event watches and timers; interest_set and watch_add say how they work), Nexus workers an owner lets you run (solo_worker_connections), this device's plugins (plugin_list; install, update, enable and remove are requests your owner confirms)${owned}${plugins}.`; + return `Read and act in Buzz as this agent. Search for the rest when you need them: your attention (Interests, event watches and timers; interest_set and watch_add say how they work), Nexus workers an owner lets you run (solo_worker_connections), this device's plugins (plugin_list; install, update, enable and remove are requests your owner confirms)${plugins}.`; } /** Changes when the added tools change, so a session that keeps the list it @@ -170,17 +161,12 @@ export async function callSessionTool( ...(typeof args.command === "string" ? { command: args.command } : {}), }); let reply: Reply; - let held: (() => void) | undefined; try { - if (isOwnerTool(name) && !added.some((tool) => tool.name === name)) - ({ text: reply, held } = await callOwnerTool(client, name, args)); - else reply = await dispatchSessionTool(client, context, name, args, added); + reply = await dispatchSessionTool(client, context, name, args, added); } catch (error) { return checkedToolError(client, session, error); } await client.protect?.({ kind: "tool-result", session, content: reply }); - // An owner read is released only while the binding it was read with holds. - held?.(); return reply; } @@ -206,8 +192,8 @@ async function dispatchSessionTool( ): Promise { const tool = added.find((tool) => tool.name === name); if (tool) { - // A plugin's tool doesn't get the agent's device settings or owner access. - const { settings: _, ownerAccess: __, ...plugin } = client; + // A plugin's tool doesn't get the agent's device settings. + const { settings: _, ...plugin } = client; return tool.call(plugin, context, args); } if (isSoloWorkerTool(name)) return callSoloWorkerTool(client, name, args); diff --git a/src/buzz-mcp/tools.test.ts b/src/buzz-mcp/tools.test.ts index 7777d3b500..598a0e8b88 100644 --- a/src/buzz-mcp/tools.test.ts +++ b/src/buzz-mcp/tools.test.ts @@ -152,10 +152,8 @@ it.each([false, true])( if (property === "protect") return protectedSession ? protect : undefined; if (property === "nativeToolGate") return undefined; - // The tool list checks whether settings can change and owner tools - // apply here. - if (property === "settings" || property === "ownerAccess") - return undefined; + // The tool list checks whether settings can change here. + if (property === "settings") return undefined; throw new Error(`Help must not access the host: ${String(property)}`); }, }); diff --git a/src/buzz-mcp/tools.ts b/src/buzz-mcp/tools.ts index 309c785c6d..1221564755 100644 --- a/src/buzz-mcp/tools.ts +++ b/src/buzz-mcp/tools.ts @@ -419,10 +419,10 @@ export const TOOLS: readonly Tool[] = [ ]; /** Message kinds as the CLI reads them. */ -export const MESSAGES = [9, 40002, 40008, 45001, 45003]; -export const THREAD = [9, 40002, 40008, 45003]; -export const SEARCH = [9, 40002, 45001, 45003]; -export const HEX = /^[0-9a-f]{64}$/; +const MESSAGES = [9, 40002, 40008, 45001, 45003]; +const THREAD = [9, 40002, 40008, 45003]; +const SEARCH = [9, 40002, 45001, 45003]; +const HEX = /^[0-9a-f]{64}$/; /** A NIP-23 long-form note, as `buzz notes` writes it. */ const NOTE = 30023; /** A repository name the community accepts. */ @@ -2188,7 +2188,7 @@ class Tools { } /** A message's line in a read, omitting what `scope` names. */ -export function messageLine( +function messageLine( event: BuzzEvent, name: string | undefined, scope: Readonly<{ channel?: string; root?: string }>, @@ -2214,7 +2214,7 @@ export function messageLine( } /** The root of the thread `event` is in, by its NIP-10 markers. */ -export function rootOf(event: BuzzEvent) { +function rootOf(event: BuzzEvent) { const tags = event.tags.filter( (t) => t[0] === "e" && t.length >= 4 && HEX.test(t[1] ?? ""), ); @@ -2223,31 +2223,20 @@ export function rootOf(event: BuzzEvent) { return ((root && reply ? root : reply)?.[1] ?? event.id) as string; } -/** Messages oldest first, each showing its latest eligible edit: its - * author's, or in its channel its verified owner's (`ownerOf`), as the app - * folds a chat message or forum post. */ -export function edited( - events: readonly BuzzEvent[], - ownerOf?: (author: string) => string | undefined, -) { +/** Messages oldest first, each showing its author's latest edit. */ +function edited(events: readonly BuzzEvent[]) { const messages = events.filter((event) => event.kind !== 40003); const byId = new Map(messages.map((event) => [event.id, event])); - const eligible = (edit: BuzzEvent, message: BuzzEvent) => - edit.pubkey === message.pubkey || - ([9, 40002].includes(message.kind) && - tag(edit, "h") === tag(message, "h") && - edit.pubkey === ownerOf?.(message.pubkey)); const edits = new Map(); for (const event of events) if (event.kind === 40003) { const target = tag(event, "e"); - const message = target && byId.get(target); + const message = target ? byId.get(target) : undefined; const last = target && edits.get(target); - // Eligibility first, so an ineligible newer edit hides none. + // Authorship first, so someone else's newer edit hides none. if ( target && - message && - eligible(event, message) && + message?.pubkey === event.pubkey && (!last || last.created_at < event.created_at) ) edits.set(target, event); @@ -2421,7 +2410,7 @@ function latestNotes(events: readonly BuzzEvent[]) { return [...latest.values()].sort((a, b) => b.created_at - a.created_at); } -export function pubkey(value: string) { +function pubkey(value: string) { if (HEX.test(value.toLowerCase())) return value.toLowerCase(); try { const decoded = decode(value); @@ -2723,7 +2712,7 @@ function joinRoleArg(args: Args) { } /** A `since` or `before` argument: a Unix time in seconds, inclusive. */ -export function unixTime(args: Args, key: string) { +function unixTime(args: Args, key: string) { const value = args[key]; if (value === undefined) return undefined; // In milliseconds, a time from this century is past the year 5000. @@ -2761,10 +2750,7 @@ function required(args: Args, name: string) { throw new Error(`${name} is required.`); return value; } -export const bound = ( - value: number | undefined, - fallback: number, - max: number, -) => Math.max(1, Math.min(max, Math.floor(value ?? fallback))); -export const time = (seconds: number) => +const bound = (value: number | undefined, fallback: number, max: number) => + Math.max(1, Math.min(max, Math.floor(value ?? fallback))); +const time = (seconds: number) => `${new Date(seconds * 1000).toISOString().slice(0, 16)}Z`; diff --git a/src/features/agent-harness/kit.ts b/src/features/agent-harness/kit.ts index 346b24384b..56f76c7b28 100644 --- a/src/features/agent-harness/kit.ts +++ b/src/features/agent-harness/kit.ts @@ -454,7 +454,7 @@ export function harnessKit(sources: Sources): HarnessKit { }); const added = sources.added(); return respond(client, context, message, added, { - instructions: sessionHint(added, !!client.ownerAccess), + instructions: sessionHint(added), }); }; }, diff --git a/src/features/agents/owner-access.test.ts b/src/features/agents/owner-access.test.ts deleted file mode 100644 index 223b1761b5..0000000000 --- a/src/features/agents/owner-access.test.ts +++ /dev/null @@ -1,1106 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { BuzzClient } from "../../buzz-mcp/client"; -import { appClient } from "../../buzz-mcp/app-client"; -import { callSessionTool } from "../../buzz-mcp/session-tools"; -import type { ProtectionAction } from "../agent-protection/service"; -import { - type ChannelLifecycleSettings, - lifecycleSettings, -} from "../relay/channel-lifecycle-protocol"; -import type { ChannelList, ChannelSummary } from "../relay/contracts"; -import type { ReadFilter, RelayEvent } from "../relay/events"; -import type { AgentHandle } from "./service"; -import { - createOwnerAccess, - type OwnerBinding, - type OwnerSession, - ownerMcpEnabled, -} from "./owner-access"; - -const owner = "a".repeat(64); -const agent = "b".repeat(64); -const person = "c".repeat(64); -const otherOwner = "d".repeat(64); -const relayAuthor = "e".repeat(64); -const community = "https://relay.example.test"; -const joined = "11111111-1111-4111-8111-111111111111"; -const archived = "22222222-2222-4222-8222-222222222222"; -const system = "44444444-4444-4444-8444-444444444444"; -const unlisted = "66666666-6666-4666-8666-666666666666"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -const record = (kind: number, id: string, tags: string[][]) => - ({ - id: `${kind}${id}`.padEnd(64, "0").slice(0, 64), - pubkey: relayAuthor, - kind, - created_at: 100, - content: "", - tags: [["d", id], ...tags], - sig: "", - }) as unknown as RelayEvent; -/** The relay-signed state of channel `id`: owners/admins and the roster. */ -function signed( - id: string, - roles: Readonly>, - metadata: string[][] = [["t", "stream"], ["private"]], -) { - return [ - record(39000, id, metadata), - record( - 39001, - id, - Object.entries(roles) - .filter(([, role]) => role === "owner" || role === "admin") - .map(([key, role]) => ["p", key, role]), - ), - record( - 39002, - id, - Object.entries(roles).map(([key, role]) => ["p", key, "", role]), - ), - ]; -} - -/** The owner's session: only reads, so any other member reached fails. Its - * lifecycle owner answers from the records last read, with direct roles alone. */ -function fakeSession( - list: Partial & { channels: ChannelSummary[] }, - events: () => readonly RelayEvent[] = () => [], - lifecycle?: (id: string) => ChannelLifecycleSettings, - content: readonly RelayEvent[] = [], -) { - const touched = new Set(); - const readRemote = vi.fn( - async ( - filters: readonly ReadFilter[], - options?: { signal?: AbortSignal }, - ): Promise => { - options?.signal?.throwIfAborted(); - const wanted = filters.flatMap((filter) => filter["#d"] ?? []); - return events().filter( - (event) => - !wanted.length || - event.tags.some( - ([key, value]) => key === "d" && wanted.includes(value ?? ""), - ), - ); - }, - ); - // The relay's own answer, newest first per filter, as `snapshotRead` gives. - const snapshotRead = vi.fn( - async ( - filters: readonly ReadFilter[], - options: { signal?: AbortSignal | undefined }, - ) => { - options.signal?.throwIfAborted(); - // As the relay reader does. - if ( - filters.some( - ({ limit }) => limit === undefined || limit < 1 || limit > 500, - ) - ) - throw new Error("Relay read limit must be between 1 and 500"); - return filters.flatMap((filter) => - content - .filter((event) => matches(filter, event)) - .sort((a, b) => b.created_at - a.created_at) - .slice(0, filter.limit), - ); - }, - ); - const ensureList = vi.fn(); - const ensure = vi.fn(async () => {}); - const load = vi.fn( - async ( - id: string, - signal?: AbortSignal, - records?: readonly RelayEvent[], - ) => { - signal?.throwIfAborted(); - if (lifecycle) return lifecycle(id); - return lifecycleSettings(records ?? events(), id, owner, relayAuthor); - }, - ); - const target = { - relayAuthor, - channels: { - list: () => ({ status: "ready", ...list }) as ChannelList, - // Never in list(), but a lookup can return one. - get: (id: string): ChannelSummary | undefined => - id === system - ? { id, name: "App data", channelType: "system", members: [owner] } - : undefined, - ensureList, - }, - readRemote, - names: { resolve: (key: string) => (key === person ? "Pat" : undefined) }, - profiles: { - ensure, - snapshot: () => new Map([[owner, { name: "Owner" }]]), - }, - // Reads only: it has no `run`. - channelLifecycle: { load }, - snapshotRead, - }; - const session = new Proxy(target, { - get(object, key, receiver) { - touched.add(key); - if (!(key in object)) throw new Error(`Unexpected ${String(key)}`); - return Reflect.get(object, key, receiver); - }, - }) as unknown as OwnerSession; - return { - session, - readRemote, - snapshotRead, - ensureList, - ensure, - load, - touched, - }; -} - -function matches(filter: ReadFilter, event: RelayEvent) { - const tagged = (name: string) => { - const wanted = filter[`#${name}`]; - return ( - !wanted || - event.tags.some( - ([key, value]) => key === name && wanted.includes(value ?? ""), - ) - ); - }; - return ( - (!filter.ids || filter.ids.includes(event.id)) && - (!filter.kinds || filter.kinds.includes(event.kind)) && - (!filter.authors || filter.authors.includes(event.pubkey)) && - (filter.since === undefined || event.created_at >= filter.since) && - (filter.until === undefined || event.created_at <= filter.until) && - (filter.before_id === undefined || event.id !== filter.before_id) && - (filter.search === undefined || event.content.includes(filter.search)) && - tagged("h") && - tagged("e") - ); -} - -/** A message in channel `h`; `key` names its ID. */ -const post = ( - key: string, - h: string, - created_at: number, - content: string, - tags: string[][] = [], - patch: Partial = {}, -) => - ({ - id: key.repeat(64).slice(0, 64), - pubkey: person, - kind: 9, - created_at, - content, - tags: [["h", h], ...tags], - sig: "", - ...patch, - }) as unknown as RelayEvent; - -const channels: ChannelSummary[] = [ - { - id: joined, - name: "Plans", - channelType: "stream", - private: true, - viewerRole: "owner", - members: [owner, person], - owners: [owner], - }, - { - id: archived, - name: "Old", - channelType: "forum", - visibility: "public", - archived: true, - members: [owner, person, agent], - }, -]; - -function bound(session: OwnerSession, viewer = owner) { - const controller = new AbortController(); - const first: OwnerBinding = { - viewer, - origin: community, - session, - signal: controller.signal, - }; - let binding: OwnerBinding | undefined = first; - return { - first, - bind: () => binding, - controller, - set(next: OwnerBinding | undefined) { - binding = next; - }, - }; -} - -function started() { - let resolve = () => {}; - const promise = new Promise((done) => { - resolve = done; - }); - return { promise, resolve }; -} - -describe("ownerMcpEnabled", () => { - it("is off by default and for every other origin", () => { - vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", ""); - expect(ownerMcpEnabled(community)).toBe(false); - vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", community); - expect(ownerMcpEnabled(community)).toBe(true); - expect(ownerMcpEnabled("wss://relay.example.test")).toBe(true); - for (const other of [ - "https://other.example.test", - "https://relay.example.test.evil.test", - "https://relay.example.test:8443", - "not a community", - null, - ]) - expect(ownerMcpEnabled(other)).toBe(false); - }); -}); - -describe("owner access", () => { - it("lists the owner's channels, including ones the agent is not in, as the owner", async () => { - const fake = fakeSession({ channels }); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channels()).resolves.toEqual({ - execution: { agent, owner, community }, - partial: false, - channels: [ - { - id: joined, - name: "Plans", - type: "stream", - visibility: "private", - archived: false, - role: "owner", - members: 2, - }, - { - id: archived, - name: "Old", - type: "forum", - visibility: "open", - archived: true, - // Listed as a member with no role: which role is not known. - role: "unknown", - members: 3, - }, - ], - }); - expect(fake.ensureList).not.toHaveBeenCalled(); - }); - - it.each([ - [{ status: "loading" as const }, true], - [{ status: "error" as const, error: "offline" }, true], - [{ coverage: "partial" as const }, false], - ])("marks a list that is not complete partial: %j", async (state, asks) => { - const fake = fakeSession({ channels: [], ...state }); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - const list = await access.channels(); - expect(list).toMatchObject({ partial: true, channels: [] }); - if ("error" in state) expect(list.error).toBe("offline"); - expect(fake.ensureList).toHaveBeenCalledTimes(asks ? 1 : 0); - }); - - it("answers a channel with the relay's current roles and rights", async () => { - // The listed role is stale: the fresh roster says admin. - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "admin", [person]: "member", [agent]: "bot" }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - const details = await access.channel(joined); - expect(details).toMatchObject({ - execution: { agent, owner, community }, - channel: { id: joined, role: "admin", members: 3, visibility: "private" }, - // An admin, not the sole owner the list says: no Delete. - capabilities: { - manageRoles: true, - removeMembers: true, - archive: true, - unarchive: false, - delete: false, - }, - }); - expect(fake.readRemote).toHaveBeenCalledExactlyOnceWith( - [39000, 39001, 39002].map((kind) => ({ - kinds: [kind], - consistency: "strong", - authors: [relayAuthor], - "#d": [joined], - limit: 1, - })), - expect.objectContaining({ priority: "foreground" }), - ); - }); - - it.each([ - [ - "archived since it was listed", - [["t", "stream"], ["private"], ["archived", "true"]], - { type: "stream", archived: true }, - { archive: false, unarchive: true, delete: false }, - ], - [ - "a DM, whatever the list says", - [["t", "dm"], ["private"]], - { type: "dm", archived: false }, - { archive: false, unarchive: false, delete: false }, - ], - ])( - "answers the current state of a channel %s", - async (_what, metadata, channel, rights) => { - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "owner", [person]: "member" }, metadata), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channel(joined)).resolves.toMatchObject({ - channel: { ...channel, role: "owner" }, - capabilities: rights, - }); - }, - ); - - it("offers lifecycle actions the owner has through an agent it owns", async () => { - // A member by role, owning the channel's owner-role agent: the session's - // lifecycle owner verifies that, as the relay does. - const fake = fakeSession( - { channels }, - () => signed(joined, { [owner]: "member", [agent]: "owner" }), - (id) => ({ - ...lifecycleSettings( - signed(joined, { [owner]: "member", [agent]: "owner" }), - id, - owner, - relayAuthor, - ), - canArchive: true, - canDelete: true, - }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channel(joined)).resolves.toMatchObject({ - channel: { role: "member" }, - capabilities: { - manageRoles: false, - removeMembers: false, - archive: true, - unarchive: false, - delete: true, - }, - }); - // It judges the writer-backed records just read, not a replica's. - expect(fake.readRemote).toHaveBeenCalledOnce(); - const [filters] = fake.readRemote.mock.calls[0] ?? []; - expect(filters).toEqual( - [39000, 39001, 39002].map((kind) => - expect.objectContaining({ kinds: [kind], consistency: "strong" }), - ), - ); - expect(fake.load).toHaveBeenCalledExactlyOnceWith( - joined, - expect.any(AbortSignal), - await fake.readRemote.mock.results[0]?.value, - ); - }); - - it("gives an owner without a role no rights, whatever the list says", async () => { - const fake = fakeSession({ channels }, () => - signed(joined, { [person]: "owner" }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channel(joined)).resolves.toMatchObject({ - channel: { role: null }, - capabilities: { - manageRoles: false, - removeMembers: false, - archive: false, - unarchive: false, - delete: false, - }, - }); - expect(fake.load).not.toHaveBeenCalled(); - }); - - it.each([ - ["an unknown channel", "33333333-3333-4333-8333-333333333333", () => []], - ["a channel the relay withholds", joined, () => []], - [ - "unverifiable roles", - joined, - () => signed(joined, { [owner]: "owner" }).slice(0, 1), - ], - ["an ID that is not a channel's", "general", () => []], - [ - "a system channel", - system, - () => signed(system, { [owner]: "owner" }, [["t", "system"]]), - ], - ])("fails for %s", async (_what, id, events) => { - const fake = fakeSession({ channels }, events); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channel(id)).rejects.toThrow(); - await expect( - access.members(id, { offset: 0, limit: 10 }), - ).rejects.toThrow(); - }); - - it("reads nothing more from a channel the relay says is a system channel, whatever the list says", async () => { - const untyped = "55555555-5555-4555-8555-555555555555"; - const fake = fakeSession( - { channels: [{ id: untyped, name: "Setup", members: [owner] }] }, - () => signed(untyped, { [owner]: "owner" }, [["t", "system"]]), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect(access.channel(untyped)).rejects.toThrow( - "not in your owner's channels", - ); - await expect( - access.members(untyped, { offset: 0, limit: 10 }), - ).rejects.toThrow("not in your owner's channels"); - expect(fake.readRemote).toHaveBeenCalledTimes(2); - expect(fake.ensure).not.toHaveBeenCalled(); - }); - - it("reads no roster for a channel outside the owner's channels", async () => { - const fake = fakeSession({ channels: [] }, () => - signed(joined, { [owner]: "owner" }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect( - access.members(joined, { offset: 0, limit: 10 }), - ).rejects.toThrow("not in your owner's channels"); - expect(fake.readRemote).not.toHaveBeenCalled(); - }); - - it("pages the roster with roles and names", async () => { - const fake = fakeSession({ channels }, () => - signed(joined, { - [owner]: "owner", - [person]: "guest", - [agent]: "bot", - [otherOwner]: "custom", - }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect( - access.members(joined, { offset: 1, limit: 2 }), - ).resolves.toEqual({ - execution: { agent, owner, community }, - channel: joined, - total: 4, - members: [ - { pubkey: person, role: "guest", name: "Pat" }, - { pubkey: agent, role: "bot" }, - ], - }); - expect(fake.ensure).toHaveBeenCalledExactlyOnceWith( - [person, agent], - "foreground", - ); - await expect( - access.members(joined, { offset: 3, limit: 2 }), - ).resolves.toMatchObject({ members: [{ role: "unknown" }] }); - }); - - it("only reads", async () => { - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "owner" }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await access.channels(); - await access.channel(joined); - await access.members(joined, { offset: 0, limit: 10 }); - await access.messages({ channel: joined, limit: 5 }); - await access.messages({ search: "plan", limit: 5 }); - await access.canvas(joined); - await access.canvasHistory(joined, {}); - expect( - [...fake.touched].every((key) => - [ - "relayAuthor", - "channels", - "readRemote", - "names", - "profiles", - "channelLifecycle", - "snapshotRead", - ].includes(String(key)), - ), - ).toBe(true); - // Content comes only from the relay's own answer, never through the - // session's projected reads; the strong read is relay-signed state alone. - for (const [filters] of fake.readRemote.mock.calls) - for (const filter of filters as readonly ReadFilter[]) - expect( - filter.kinds?.every((kind) => kind >= 39000 && kind <= 39002), - ).toBe(true); - }); - - it.each(["no binding", "another account's binding", "a replaced binding"])( - "reads nothing with %s", - async (state) => { - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "owner" }), - ); - const binding = bound(fake.session); - if (state === "no binding") binding.set(undefined); - else if (state === "another account's binding") - binding.set({ ...binding.first, viewer: otherOwner }); - else binding.controller.abort(); - const access = createOwnerAccess({ pubkey: agent, owner }, binding.bind); - await expect(access.channels()).rejects.toThrow("not connected"); - await expect(access.channel(joined)).rejects.toThrow("not connected"); - expect(fake.readRemote).not.toHaveBeenCalled(); - }, - ); - - it.each(["signs in as someone else", "reconnects", "disconnects"])( - "returns nothing when the owner's connection %s during the call", - async (change) => { - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - const reading = started(); - const fake = fakeSession({ channels }); - const binding = bound(fake.session); - fake.readRemote.mockImplementation(async () => { - reading.resolve(); - await held; - return signed(joined, { [owner]: "owner" }); - }); - const access = createOwnerAccess({ pubkey: agent, owner }, binding.bind); - const call = access.channel(joined); - await reading.promise; - if (change === "signs in as someone else") - binding.set({ ...binding.first, viewer: otherOwner }); - else if (change === "reconnects") - binding.set({ - ...binding.first, - session: fakeSession({ channels }).session, - }); - else binding.set(undefined); - release(); - await expect(call).rejects.toThrow("changed during the call"); - }, - ); - - it.each(["signs in as someone else", "reconnects", "disconnects"])( - "withholds an answer when the owner's connection %s while its result is reviewed", - async (change) => { - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "owner" }), - ); - const binding = bound(fake.session); - const access = createOwnerAccess({ pubkey: agent, owner }, binding.bind); - const reviewing = started(); - const approve = started(); - const protect = vi.fn(async (action: ProtectionAction) => { - if (action.kind !== "tool-result") return; - reviewing.resolve(); - await approve.promise; - }); - const client = { - pubkey: agent, - key: agent, - ownerAccess: access, - protect, - } as unknown as BuzzClient; - const call = callSessionTool(client, {}, "owner_channel_get", { - channel: joined, - }); - await reviewing.promise; - const replacement = fakeSession({ channels }); - if (change === "signs in as someone else") - binding.set({ ...binding.first, viewer: otherOwner }); - else if (change === "reconnects") - binding.set({ ...binding.first, session: replacement.session }); - else binding.set(undefined); - approve.resolve(); - await expect(call).rejects.toThrow("changed during the call"); - // Checked, not read again or read elsewhere. - expect(fake.readRemote).toHaveBeenCalledOnce(); - expect(replacement.readRemote).not.toHaveBeenCalled(); - }, - ); - - it("answers only reads it made itself", () => { - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fakeSession({ channels }).session).bind, - ); - expect(() => access.held({ agent, owner, community })).toThrow( - "changed during the call", - ); - }); - - it("returns nothing once the binding is replaced mid-read", async () => { - const reading = started(); - const fake = fakeSession({ channels }); - const binding = bound(fake.session); - fake.readRemote.mockImplementation( - (_filters, options) => - new Promise((_resolve, reject) => { - reading.resolve(); - options?.signal?.addEventListener("abort", () => - reject(options.signal?.reason), - ); - }), - ); - const access = createOwnerAccess({ pubkey: agent, owner }, binding.bind); - const call = access.channel(joined); - await reading.promise; - binding.controller.abort(); - await expect(call).rejects.toThrow("changed during the call"); - }); - - it("stops when its caller cancels, before or during the read", async () => { - const fake = fakeSession({ channels }, () => - signed(joined, { [owner]: "owner" }), - ); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - const before = new AbortController(); - before.abort(new Error("Turn ended")); - await expect(access.channels(before.signal)).rejects.toThrow("Turn ended"); - const during = new AbortController(); - fake.readRemote.mockImplementationOnce(async () => { - during.abort(new Error("Turn ended")); - return signed(joined, { [owner]: "owner" }); - }); - await expect(access.channel(joined, during.signal)).rejects.toThrow( - "Turn ended", - ); - }); - - it("keeps each owner's reads to that owner's own session", async () => { - const first = fakeSession({ channels }); - const second = fakeSession({ - channels: [{ id: archived, name: "Theirs", members: [otherOwner] }], - }); - const mine = bound(first.session); - const theirs = bound(second.session, otherOwner); - const a = createOwnerAccess({ pubkey: agent, owner }, mine.bind); - const b = createOwnerAccess( - { pubkey: person, owner: otherOwner }, - theirs.bind, - ); - await a.channels(); - const list = await b.channels(); - expect(list.channels.map(({ name }) => name)).toEqual(["Theirs"]); - expect(list.execution).toEqual({ - agent: person, - owner: otherOwner, - community, - }); - // The first owner's agent never reads through the second owner's session. - mine.set(theirs.first); - await expect(a.channels()).rejects.toThrow("not connected"); - }); -}); - -describe("owner reads", () => { - /** The relay's signed state of the owner's two channels; the agent is in - * neither private channel's roster. */ - const state = () => [ - ...signed(joined, { [owner]: "owner", [person]: "member" }), - ...signed( - archived, - { [owner]: "member", [person]: "member", [agent]: "bot" }, - [["t", "forum"], ["public"], ["archived", "true"]], - ), - ]; - const reader = (content: readonly RelayEvent[], list = { channels }) => { - const fake = fakeSession(list, state, undefined, content); - const binding = bound(fake.session); - return { - fake, - binding, - access: createOwnerAccess({ pubkey: agent, owner }, binding.bind), - }; - }; - const contentReads = (fake: ReturnType) => - fake.snapshotRead.mock.calls.map(([filters]) => filters); - - it("reads a private channel the agent is not in, a page at a time, with edits and names", async () => { - const one = post("1", joined, 10, "first"); - const two = post("2", joined, 20, "second"); - const three = post("3", joined, 30, "third"); - const edit = post("4", joined, 31, "third, fixed", [["e", three.id]], { - kind: 40003, - }); - const elsewhere = post("5", archived, 40, "elsewhere"); - const { fake, access } = reader([one, two, three, edit, elsewhere]); - const page = await access.messages({ channel: joined, limit: 2 }); - expect(page).toEqual({ - execution: { agent, owner, community }, - channel: joined, - events: [three, two, edit], - names: { [person]: "Pat" }, - more: { before: 20, beforeId: two.id }, - }); - expect(contentReads(fake)[0]).toEqual([ - expect.objectContaining({ "#h": [joined], limit: 3 }), - ]); - // Read with the call's signal, from the relay's own answer. - expect(fake.snapshotRead.mock.calls[0]?.[1]).toEqual({ - signal: expect.any(AbortSignal), - }); - await expect( - access.messages({ - channel: joined, - limit: 2, - before: 20, - beforeId: two.id, - }), - ).resolves.toMatchObject({ events: [one], channel: joined }); - // An archived open forum reads the same way. - await expect( - access.messages({ channel: archived, limit: 5 }), - ).resolves.toMatchObject({ events: [elsewhere], channel: archived }); - }); - - it("names the owners its Buzz has verified for a page's authors", async () => { - const one = post("1", joined, 10, "first"); - const { fake, access } = reader([one]); - Object.assign(fake.session.profiles, { - owner: (key: string) => (key === person ? owner : undefined), - }); - await expect( - access.messages({ channel: joined, limit: 5 }), - ).resolves.toMatchObject({ owners: { [person]: owner } }); - }); - - it("says when a page's edits fill the one edit read", async () => { - const message = post("1", joined, 10, "first"); - const edits = Array.from({ length: 500 }, (_, i) => - post( - `a${i.toString(16).padStart(3, "0")}`, - joined, - 11 + i, - `v${i}`, - [["e", message.id]], - { kind: 40003 }, - ), - ); - const { access } = reader([message, ...edits]); - await expect( - access.messages({ channel: joined, limit: 5 }), - ).resolves.toMatchObject({ editsLimited: true }); - }); - - it("reads a whole thread from any of its messages", async () => { - const root = post("1", joined, 10, "root"); - const reply = (key: string, at: number) => - post(key, joined, at, `reply ${key}`, [ - ["e", root.id, "", "root"], - ["e", root.id, "", "reply"], - ]); - const first = reply("2", 20); - const second = reply("3", 30); - const { access } = reader([ - root, - first, - second, - post("4", joined, 40, "x"), - ]); - const page = await access.messages({ thread: second.id, limit: 1 }); - expect(page).toMatchObject({ - channel: joined, - root: root.id, - events: [root, second], - more: { before: 30, beforeId: second.id }, - }); - await expect( - access.messages({ - thread: second.id, - limit: 1, - before: 30, - beforeId: second.id, - }), - ).resolves.toMatchObject({ root: root.id, events: [first] }); - }); - - it("searches only the owner's channels, and says when that is incomplete or limited", async () => { - const mine = post("1", joined, 10, "the plan"); - const unknown = post("2", unlisted, 20, "their plan"); - const hidden = post("3", system, 30, "system plan"); - const { access } = reader([mine, unknown, hidden]); - await expect( - access.messages({ search: "plan", limit: 5 }), - ).resolves.toEqual({ - execution: { agent, owner, community }, - events: [mine], - names: { [person]: "Pat" }, - }); - await expect( - access.messages({ search: "plan", limit: 3 }), - ).resolves.toMatchObject({ events: [mine], limited: true }); - const loading = reader([mine], { - channels: [], - status: "loading", - } as never).access; - await expect( - loading.messages({ search: "plan", limit: 5 }), - ).resolves.toMatchObject({ events: [], partial: true }); - }); - - it.each([ - ["a channel outside the owner's channels", unlisted, "not in your owner's"], - ["a system channel", system, "not in your owner's"], - ["an ID that is not a channel's", "general", "must be a channel ID"], - ])("reads nothing from %s", async (_what, id, error) => { - const { fake, access } = reader([post("1", id, 10, "secret")]); - await expect(access.messages({ channel: id, limit: 5 })).rejects.toThrow( - error, - ); - await expect(access.canvas(id)).rejects.toThrow(error); - await expect(access.canvasHistory(id, {})).rejects.toThrow(error); - expect(fake.snapshotRead).not.toHaveBeenCalled(); - }); - - it("reads nothing from a channel the relay withholds from the owner", async () => { - const fake = fakeSession({ channels }, () => [], undefined, [ - post("1", joined, 10, "secret"), - ]); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(fake.session).bind, - ); - await expect( - access.messages({ channel: joined, limit: 5 }), - ).rejects.toThrow("not found or your owner cannot see it"); - expect(fake.snapshotRead).not.toHaveBeenCalled(); - }); - - it("answers a message or thread outside the owner's channels as missing, reading no more", async () => { - const theirs = post("1", unlisted, 10, "secret"); - const { fake, access } = reader([theirs]); - await expect( - access.messages({ thread: theirs.id, limit: 5 }), - ).rejects.toThrow(`No message ${theirs.id}`); - await expect( - access.messages({ thread: "f".repeat(64), limit: 5 }), - ).rejects.toThrow("No message"); - // Only the message lookups. - for (const filters of contentReads(fake)) - expect(filters).toEqual([expect.objectContaining({ limit: 1 })]); - expect(fake.readRemote).not.toHaveBeenCalled(); - }); - - it("is unavailable while the owner's session cannot read the relay", async () => { - const fake = fakeSession({ channels }, state); - const session = new Proxy(fake.session, { - get: (object, key) => - key === "snapshotRead" ? undefined : Reflect.get(object, key), - }); - const access = createOwnerAccess( - { pubkey: agent, owner }, - bound(session).bind, - ); - await expect( - access.messages({ channel: joined, limit: 5 }), - ).rejects.toThrow("owner tools are unavailable now"); - await expect(access.canvas(joined)).rejects.toThrow( - "owner tools are unavailable now", - ); - }); - - it("reads the current canvas, a revision of it, and its history a page at a time", async () => { - const canvas = (key: string, h: string, at: number, content: string) => - post(key, h, at, content, [], { kind: 40100, pubkey: owner }); - const older = canvas("1", joined, 10, "# Draft"); - const newer = canvas("2", joined, 20, "# Plan"); - const other = canvas("3", archived, 30, "# Other"); - const { fake, access } = reader([older, newer, other]); - await expect(access.canvas(joined)).resolves.toEqual({ - execution: { agent, owner, community }, - channel: joined, - canvas: newer, - }); - await expect(access.canvas(joined, older.id)).resolves.toMatchObject({ - canvas: older, - }); - // A revision is read from the writer, so one history just listed is found. - expect(contentReads(fake).at(-1)).toEqual([ - expect.objectContaining({ ids: [older.id], consistency: "strong" }), - ]); - await expect(access.canvas(joined, other.id)).rejects.toThrow( - `No revision ${other.id} of channel ${joined}'s canvas.`, - ); - await expect(access.canvasHistory(joined, {})).resolves.toEqual({ - execution: { agent, owner, community }, - channel: joined, - revisions: [newer, older], - }); - // The first page is the writer's; later ones follow the cursor. - expect(contentReads(fake).at(-1)).toEqual([ - expect.objectContaining({ consistency: "strong", "#h": [joined] }), - ]); - await expect( - access.canvasHistory(joined, { before: 20, beforeId: newer.id }), - ).resolves.toMatchObject({ revisions: [older] }); - expect(contentReads(fake).at(-1)).toEqual([ - expect.not.objectContaining({ consistency: "strong" }), - ]); - expect(contentReads(fake).at(-1)).toEqual([ - expect.objectContaining({ until: 20, before_id: newer.id }), - ]); - // A channel with no canvas has none. - const empty = reader([]).access; - await expect(empty.canvas(joined)).resolves.not.toHaveProperty("canvas"); - }); - - it("returns no content once the owner's connection changes mid-read", async () => { - const reading = started(); - let release!: () => void; - const held = new Promise((resolve) => { - release = resolve; - }); - const { fake, binding, access } = reader([post("1", joined, 10, "secret")]); - const replacement = fakeSession({ channels }, state); - fake.snapshotRead.mockImplementationOnce(async () => { - reading.resolve(); - await held; - return [post("1", joined, 10, "secret")]; - }); - const call = access.messages({ channel: joined, limit: 5 }); - await reading.promise; - binding.set({ ...binding.first, session: replacement.session }); - release(); - await expect(call).rejects.toThrow("changed during the call"); - expect(replacement.snapshotRead).not.toHaveBeenCalled(); - expect(replacement.readRemote).not.toHaveBeenCalled(); - }); - - it("stops when its caller cancels during a content read", async () => { - const { fake, access } = reader([post("1", joined, 10, "secret")]); - const during = new AbortController(); - fake.snapshotRead.mockImplementationOnce(async () => { - during.abort(new Error("Turn ended")); - return [post("1", joined, 10, "secret")]; - }); - await expect( - access.messages({ channel: joined, limit: 5 }, during.signal), - ).rejects.toThrow("Turn ended"); - const before = new AbortController(); - before.abort(new Error("Turn ended")); - await expect( - access.canvas(joined, undefined, before.signal), - ).rejects.toThrow("Turn ended"); - }); - - it("never gives one owner's agent another owner's content", async () => { - const mine = reader([post("1", joined, 10, "mine")]); - const theirs = fakeSession( - { channels: [{ id: joined, name: "Plans", members: [otherOwner] }] }, - () => signed(joined, { [otherOwner]: "owner" }), - undefined, - [post("2", joined, 20, "theirs")], - ); - const other = bound(theirs.session, otherOwner); - await expect( - mine.access.messages({ channel: joined, limit: 5 }), - ).resolves.toMatchObject({ events: [{ content: "mine" }] }); - // The host now binds the other owner: this agent reads nothing there. - mine.binding.set(other.first); - await expect( - mine.access.messages({ channel: joined, limit: 5 }), - ).rejects.toThrow("not connected"); - await expect(mine.access.canvas(joined)).rejects.toThrow("not connected"); - expect(theirs.snapshotRead).not.toHaveBeenCalled(); - }); - - it("answers each owner read tool through the agent's own client, as the owner", async () => { - const message = post("1", joined, 10, "see the plan"); - const canvas = post("2", joined, 20, "# Plan", [], { - kind: 40100, - pubkey: owner, - }); - const { fake, access } = reader([message, canvas]); - const client = appClient( - { - pubkey: agent, - key: agent, - owner, - ownerAccess: access, - } as unknown as AgentHandle, - { - read: vi.fn(), - memories: async () => ({ entries: [], partial: false }), - }, - ); - const call = async (name: string, args: Record) => - JSON.parse(String(await callSessionTool(client, {}, name, args))); - const executedAs = { agent, owner, community }; - await expect( - call("owner_read", { channel: joined }), - ).resolves.toMatchObject({ - executedAs, - channel: joined, - messages: [expect.stringContaining("see the plan")], - }); - await expect(call("owner_read", { q: "plan" })).resolves.toMatchObject({ - executedAs, - messages: [expect.any(String)], - }); - await expect( - call("owner_canvas_get", { channel: joined }), - ).resolves.toMatchObject({ executedAs, id: canvas.id, canvas: "# Plan" }); - await expect( - call("owner_canvas_history", { channel: joined }), - ).resolves.toMatchObject({ executedAs, revisions: [{ id: canvas.id }] }); - // Every answer came from the relay's own reads with the owner's session. - expect(fake.snapshotRead).toHaveBeenCalled(); - }); -}); diff --git a/src/features/agents/owner-access.ts b/src/features/agents/owner-access.ts deleted file mode 100644 index a5d2304f5c..0000000000 --- a/src/features/agents/owner-access.ts +++ /dev/null @@ -1,575 +0,0 @@ -// The owner tools' host side: read-only operations the app runs with its -// signed-in human's own session, for that human's agent, in the agent's own -// community. Every call resolves the binding again and checks it once more -// before returning anything, so a sign-out, account switch or reconnect during -// a call returns nothing. -import type { - BuzzEvent, - OwnerAccess, - OwnerChannel, - OwnerExecution, - OwnerReadRequest, - OwnerRole, -} from "../../buzz-mcp/client"; -import { MESSAGES, rootOf, SEARCH, THREAD } from "../../buzz-mcp/tools"; -import { memberAuthority } from "../channel-members/administration-protocol"; -import { CANVAS_HISTORY_PAGE } from "../channel-templates/canvas-limits"; -import { communityDestination } from "../communities/destination"; -import { channelIdentity } from "../relay/channel-labels"; -import type { ChannelSummary } from "../relay/contracts"; -import type { ReadFilter, RelayEvent } from "../relay/events"; -import { - channelTypeTag, - exactLifecycleTag, - lifecycleChannelId, - lifecycleRecord, -} from "../relay/channel-lifecycle-protocol"; -import { soloLabels } from "../solo/labels"; -import type { RelaySession } from "../relay/session"; - -/** Whether this build lets agents read with their owner's access in - * `community`: its exact canonical origin is in the build's list. */ -export function ownerMcpEnabled(community: string | null | undefined) { - if (!community) return false; - try { - return (import.meta.env.VITE_BUZZ_OWNER_MCP_COMMUNITIES ?? "") - .split(" ") - .includes(communityDestination(community).url); - } catch { - return false; - } -} - -export type OwnerSession = Pick< - RelaySession, - | "relayAuthor" - | "channels" - | "readRemote" - | "names" - | "profiles" - | "channelLifecycle" - | "snapshotRead" ->; -/** The owner's live session for the agent's community, as the host binds it - * now. `signal` aborts when that binding is replaced. */ -export type OwnerBinding = Readonly<{ - viewer: string; - origin: string; - session: OwnerSession; - signal: AbortSignal; -}>; - -/** One edit read serves a whole page; the relay reader's cap. */ -const EDITS = 500; - -const unavailable = () => - new Error( - "Your owner's Buzz is not connected to this community as them, so owner tools are unavailable now.", - ); -const notOwners = (id: string) => - new Error( - `Channel ${id} is not in your owner's channels; owner_channel_list lists them.`, - ); -const changed = () => - new Error( - "Your owner's connection changed during the call, so nothing was returned. Try again.", - ); - -/** `agent`'s owner reads. `bind` answers the owner's current binding for the - * agent's community, or undefined when it may not be used now. */ -export function createOwnerAccess( - agent: Readonly<{ pubkey: string; owner: string }>, - bind: () => OwnerBinding | undefined, -): OwnerAccess { - /** The binding each answer was read with. */ - const answered = new WeakMap(); - const current = () => { - const binding = bind(); - if (!binding || binding.signal.aborted || binding.viewer !== agent.owner) - throw unavailable(); - return binding; - }; - /** Runs `read` on the current binding; its answer only while the binding - * still holds. */ - async function run( - signal: AbortSignal | undefined, - read: (binding: OwnerBinding, signal: AbortSignal) => Promise | T, - ): Promise { - signal?.throwIfAborted(); - const binding = current(); - const bound = signal - ? AbortSignal.any([signal, binding.signal]) - : binding.signal; - let result: T; - try { - result = await read(binding, bound); - } catch (error) { - signal?.throwIfAborted(); - throw binding.signal.aborted ? changed() : error; - } - signal?.throwIfAborted(); - holds(binding); - return result; - } - /** Throws unless `binding` is still the current one. */ - const holds = (binding: OwnerBinding) => { - const now = bind(); - if ( - binding.signal.aborted || - now?.session !== binding.session || - now.viewer !== binding.viewer - ) - throw changed(); - current(); - }; - const execution = (binding: OwnerBinding): OwnerExecution => { - const execution = { - agent: agent.pubkey, - owner: binding.viewer, - community: binding.origin, - }; - answered.set(execution, binding); - return execution; - }; - /** The relay-signed metadata, admin list and roster, read fresh. */ - const authority = async ( - binding: OwnerBinding, - id: string, - signal: AbortSignal, - ) => { - const { session, viewer } = binding; - const relayAuthor = session.relayAuthor; - if (!relayAuthor) throw unavailable(); - const events = await session.readRemote( - [39000, 39001, 39002].map((kind) => ({ - kinds: [kind], - consistency: "strong" as const, - authors: [relayAuthor], - "#d": [id], - limit: 1, - })), - { signal, priority: "foreground" }, - ); - signal.throwIfAborted(); - if (!events.some((event) => event.kind === 39000)) - throw new Error( - `Channel ${id} was not found or your owner cannot see it.`, - ); - const rights = memberAuthority(events, id, viewer, relayAuthor); - // Whatever the session's list says, a system channel is never read. - if (rights.system) throw notOwners(id); - // memberAuthority has verified all three records. - const metadata = lifecycleRecord( - events, - 39000, - id, - relayAuthor, - ) as RelayEvent; - return { events, rights, metadata }; - }; - /** Channel `id` as the owner's session knows it; never a system channel, - * which `list()` never shows either. */ - const listed = (session: OwnerSession, id: string) => { - const item = - session.channels.list().channels.find((each) => each.id === id) ?? - session.channels.get?.(id); - return item && item.channelType !== "system" ? item : undefined; - }; - const known = (session: OwnerSession, id: string) => { - const item = listed(session, id); - if (!item) throw notOwners(id); - return item; - }; - /** The relay's own answer to the owner's session: nothing is merged into, - * or projected from, its views, so no unread or read state changes. */ - const fetch = async ( - binding: OwnerBinding, - filters: readonly ReadFilter[], - signal: AbortSignal, - ): Promise => { - const read = binding.session.snapshotRead; - if (!read) throw unavailable(); - const events = await read(filters, { signal }); - signal.throwIfAborted(); - return events; - }; - /** Channel `id`, checked as one of the owner's against the relay's fresh - * records before anything in it is read. */ - const readable = async ( - binding: OwnerBinding, - id: string, - signal: AbortSignal, - ) => { - known(binding.session, channelId(id)); - await authority(binding, id, signal); - return id; - }; - /** Message `id` and its channel, which must be one of the owner's. */ - const message = async ( - binding: OwnerBinding, - id: string, - signal: AbortSignal, - ) => { - const [event] = await fetch( - binding, - [{ ids: [id], kinds: [...THREAD, 45001], limit: 1 }], - signal, - ); - const channel = event && tag(event, "h"); - // One outside the owner's channels reads as missing, naming no channel. - if (!event || !channel || !listed(binding.session, channel)) - throw new Error(`No message ${id} that your owner can see.`); - return { event, channel: await readable(binding, channel, signal) }; - }; - /** The latest edits of `events`, read for their authors' names too. */ - const edits = ( - binding: OwnerBinding, - events: readonly BuzzEvent[], - signal: AbortSignal, - ) => - events.length - ? fetch( - binding, - [{ kinds: [40003], "#e": events.map(({ id }) => id), limit: EDITS }], - signal, - ) - : Promise.resolve([]); - const names = async ( - session: OwnerSession, - events: readonly BuzzEvent[], - signal: AbortSignal, - ) => { - const keys = [...new Set(events.map(({ pubkey }) => pubkey))]; - if (keys.length) await session.profiles.ensure(keys, "foreground"); - signal.throwIfAborted(); - const found: Record = {}; - for (const key of keys) { - const name = - session.names.resolve(key) ?? - session.profiles.snapshot().get(key)?.name; - if (name) found[key] = name; - } - return found; - }; - const channelId = (id: string) => { - try { - return lifecycleChannelId(id); - } catch { - throw new Error("channel must be a channel ID."); - } - }; - - /** `events` with their edits and their authors' names. */ - const page = async ( - binding: OwnerBinding, - events: readonly BuzzEvent[], - signal: AbortSignal, - ) => { - const changes = await edits(binding, events, signal); - const found = await names(binding.session, events, signal); - const owners: Record = {}; - for (const { pubkey } of events) { - const owner = binding.session.profiles.owner?.(pubkey); - if (owner) owners[pubkey] = owner; - } - return { - execution: execution(binding), - events: [...events, ...changes], - names: found, - ...(Object.keys(owners).length ? { owners } : {}), - ...(changes.length >= EDITS ? { editsLimited: true as const } : {}), - }; - }; - - return Object.freeze({ - channels: (signal?: AbortSignal) => - run(signal, (binding) => { - const { channels } = binding.session; - const list = channels.list(); - // Asks the owner's session to finish its list; reads only. - if (list.status !== "ready") channels.ensureList(); - return { - execution: execution(binding), - channels: list.channels.map((item) => - ownerChannel(item, binding.viewer), - ), - partial: list.status !== "ready" || list.coverage === "partial", - ...(list.error ? { error: list.error } : {}), - }; - }), - channel: (id: string, signal?: AbortSignal) => - run(signal, async (binding, bound) => { - const summary = known(binding.session, channelId(id)); - const { events, rights, metadata } = await authority( - binding, - id, - bound, - ); - const { viewer } = binding; - const role = rights.roles[viewer]; - // Type, archive state and lifecycle rights from the records just read, - // never the session's list. - const tag = channelTypeTag(metadata); - const type: OwnerChannel["type"] = - tag === "stream" && soloLabels(channelIdentity(metadata).labels) - ? "solo" - : tag === "stream" || tag === "forum" || tag === "dm" - ? tag - : "unknown"; - // The session's lifecycle owner also grants a member who owns the - // channel's owner-role agent, as the relay does. It judges the same - // writer-backed records, never a replica that may lag them. - const offer = role - ? await binding.session.channelLifecycle.load(id, bound, events) - : undefined; - bound.throwIfAborted(); - return { - execution: execution(binding), - channel: { - ...ownerChannel(summary, viewer), - type, - archived: exactLifecycleTag(metadata, "archived") === "true", - role: role ?? null, - members: Object.keys(rights.roles).length, - ...(summary.description - ? { description: summary.description } - : {}), - ...(summary.joinRole ? { joinRole: summary.joinRole } : {}), - ...(summary.ttlSeconds ? { ttlSeconds: summary.ttlSeconds } : {}), - ...(summary.creator ? { creator: summary.creator } : {}), - ...(summary.createdAt ? { createdAt: summary.createdAt } : {}), - }, - capabilities: { - manageRoles: rights.canManage, - removeMembers: rights.canRemove, - archive: !!offer?.canArchive, - unarchive: !!offer?.canUnarchive, - delete: !!offer?.canDelete, - }, - }; - }), - members: ( - id: string, - page: Readonly<{ offset: number; limit: number }>, - signal?: AbortSignal, - ) => - run(signal, async (binding, bound) => { - known(binding.session, channelId(id)); - const { rights } = await authority(binding, id, bound); - const roster = Object.entries(rights.roles); - const shown = roster.slice(page.offset, page.offset + page.limit); - const keys = shown.map(([pubkey]) => pubkey); - const { session } = binding; - if (keys.length) await session.profiles.ensure(keys, "foreground"); - bound.throwIfAborted(); - const members = roster.map(([pubkey]) => pubkey); - return { - execution: execution(binding), - channel: id, - total: roster.length, - members: shown.map(([pubkey, role]) => { - const name = - session.names.resolve(pubkey, undefined, members) ?? - session.profiles.snapshot().get(pubkey)?.name; - return { pubkey, role, ...(name ? { name } : {}) }; - }), - }; - }), - messages: (request: OwnerReadRequest, signal?: AbortSignal) => - run(signal, async (binding, bound) => { - const { limit, before, beforeId, author, since, search } = request; - let { channel } = request; - let root: string | undefined; - if (request.thread) { - const found = await message(binding, request.thread, bound); - channel = found.channel; - root = rootOf(found.event); - } else if (channel) await readable(binding, channel, bound); - // The relay's cursor: at or before `until`, after `before_id` at a tie. - const cursor = { - ...(before !== undefined ? { until: before } : {}), - ...(beforeId ? { before_id: beforeId } : {}), - }; - const narrow = { - ...(author ? { authors: [author] } : {}), - ...(since !== undefined ? { since } : {}), - }; - if (search !== undefined) { - const found = await fetch( - binding, - [ - { - kinds: SEARCH, - search, - limit, - ...(channel ? { "#h": [channel] } : {}), - ...cursor, - ...narrow, - }, - ], - bound, - ); - // Only the owner's own channels, never a system one. - const shown = found.filter((event) => { - const id = tag(event, "h"); - return !!id && !!listed(binding.session, id); - }); - const list = binding.session.channels.list(); - const partial = - !channel && - (list.status !== "ready" || list.coverage === "partial"); - const answer = await page(binding, shown, bound); - return { - ...answer, - ...(channel ? { channel } : {}), - ...(found.length >= limit ? { limited: true as const } : {}), - ...(partial ? { partial: true as const } : {}), - }; - } - if (!channel) throw new Error("channel, thread or q is required."); - // One more than the limit tells whether older messages remain. - const found = await fetch( - binding, - root - ? [ - { - kinds: THREAD, - "#h": [channel], - "#e": [root], - limit: limit + 1, - ...cursor, - }, - ...(before === undefined - ? [{ ids: [root], kinds: [...THREAD, 45001], limit: 1 }] - : []), - ] - : [ - { - kinds: MESSAGES, - "#h": [channel], - limit: limit + 1, - ...cursor, - ...narrow, - }, - ], - bound, - ); - const inChannel = found.filter((event) => tag(event, "h") === channel); - const first = root - ? inChannel.find((event) => event.id === root) - : undefined; - const rest = inChannel.filter((event) => event !== first); - const shown = rest.slice(0, limit); - const last = shown.at(-1); - const more = - last && rest.length > limit - ? { before: last.created_at, beforeId: last.id } - : undefined; - const answer = await page( - binding, - first ? [first, ...shown] : shown, - bound, - ); - return { - ...answer, - channel, - ...(root ? { root } : {}), - ...(more ? { more } : {}), - }; - }), - canvas: (channel: string, revision?: string, signal?: AbortSignal) => - run(signal, async (binding, bound) => { - const id = await readable(binding, channel, bound); - const [head] = await fetch( - binding, - [ - { - // From the writer, as history lists it, so an ID history just - // gave is found. - ...(revision - ? { ids: [revision], consistency: "strong" as const } - : {}), - kinds: [40100], - "#h": [id], - limit: 1, - }, - ], - bound, - ); - if (revision && tag(head, "h") !== id) - throw new Error(`No revision ${revision} of channel ${id}'s canvas.`); - return { - execution: execution(binding), - channel: id, - ...(head ? { canvas: head } : {}), - }; - }), - canvasHistory: ( - channel: string, - cursor: Readonly<{ before?: number; beforeId?: string }>, - signal?: AbortSignal, - ) => - run(signal, async (binding, bound) => { - const id = await readable(binding, channel, bound); - // As the app's history pages it. The first page is read from the - // writer, so it includes a write just confirmed. - const revisions = await fetch( - binding, - [ - { - kinds: [40100], - "#h": [id], - limit: CANVAS_HISTORY_PAGE, - ...(cursor.before === undefined - ? { consistency: "strong" as const } - : { until: cursor.before }), - ...(cursor.beforeId ? { before_id: cursor.beforeId } : {}), - }, - ], - bound, - ); - return { - execution: execution(binding), - channel: id, - revisions: revisions.filter((event) => tag(event, "h") === id), - }; - }), - held: (answer: OwnerExecution) => { - const binding = answered.get(answer); - if (!binding) throw changed(); - holds(binding); - }, - }); -} - -function ownerChannel(item: ChannelSummary, viewer: string): OwnerChannel { - const type = item.channelType; - // Membership alone does not say which role the owner holds. - const role: OwnerRole | "unknown" | null | undefined = - item.viewerRole ?? - (item.members - ? item.members.includes(viewer) - ? "unknown" - : null - : undefined); - return { - id: item.id, - name: item.name, - type: - type === "stream" || type === "forum" || type === "dm" || type === "solo" - ? type - : "unknown", - visibility: item.private - ? "private" - : item.visibility === "public" - ? "open" - : (item.visibility ?? "unknown"), - archived: !!item.archived, - ...(role !== undefined ? { role } : {}), - ...(item.cached ? { cached: true as const } : {}), - ...(item.members ? { members: item.members.length } : {}), - }; -} - -const tag = (event: BuzzEvent | undefined, name: string) => - event?.tags.find((entry) => entry[0] === name)?.[1]; diff --git a/src/features/agents/runner.ts b/src/features/agents/runner.ts index e6044117e9..92440a42b2 100644 --- a/src/features/agents/runner.ts +++ b/src/features/agents/runner.ts @@ -522,8 +522,6 @@ export type RunnerHost = { tabs(agent: Agent): ReturnType; /** Buzz settings on this device, where there are any. */ settings?(): DeviceSettings | undefined; - /** Owner reads for a run of the agent, where this build enables them. */ - ownerAccess?(agent: Agent): AgentHandle["ownerAccess"]; /** Whether watch classifiers can run here. */ classifier(): ClassifierAvailability; /** The decisions service that runs classifiers, where there is one. */ @@ -2840,12 +2838,6 @@ export class AgentRunners { const lifetime = runner.controller.signal; const protection = this.host.protection?.(agent); const settings = run ? this.host.settings?.() : undefined; - // Only a bundled harness's run: an installed harness plugin is not trusted - // with its owner's access. - const ownerAccess = - run && runner.type?.revision === "bundled" - ? this.host.ownerAccess?.(agent) - : undefined; const handle = guarded( { protection: protection && { @@ -2955,7 +2947,6 @@ export class AgentRunners { tabs: () => this.host.tabs(agent), // Only a run's: a plugin acting through the agent doesn't get them. ...(settings && { settings }), - ...(ownerAccess && { ownerAccess }), notes: (channelId: string, root?: string) => { const pending = runner.notes.get(channelId) ?? []; const matches = (note: { root: string | undefined }) => diff --git a/src/features/agents/service.test.ts b/src/features/agents/service.test.ts index 6c02899b81..d79d43bf8f 100644 --- a/src/features/agents/service.test.ts +++ b/src/features/agents/service.test.ts @@ -14601,6 +14601,17 @@ it.each(["community selection", "reconnect", "identity", "runtime", "revoke"])( }, ); +// The owner-read tools #2745 and #2881 added, listed here rather than taken +// from the catalog under test. +const RETIRED_OWNER_TOOLS = [ + "owner_channel_list", + "owner_channel_get", + "owner_member_list", + "owner_read", + "owner_canvas_get", + "owner_canvas_history", +]; + describe("full external agent access", () => { async function fixture( scopes: readonly import("./external-connection").ExternalScope[] = [ @@ -14732,21 +14743,24 @@ describe("full external agent access", () => { } }); - it("never gets the owner's access a bundled harness's run has", async () => { + it("neither lists nor runs the retired owner-read tools", async () => { vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", "https://relay.example.test"); const f = await fixture(["agent"], { revision: "bundled" }); try { - // The same run's own handle does carry it. - const run = runnerGate(); - f.run.mockImplementationOnce(async ({ agent }) => run.resolve(agent)); - f.plugin.agents.wake(bot, { channel, root: "e".repeat(64), text: "hi" }); - expect(await run.promise).toHaveProperty("ownerAccess"); const names = await f.names(); expect(names).toContain("send"); - expect(names.filter((name) => name.startsWith("owner_"))).toEqual([]); - const read = await f.call("owner_channel_list", {}); - expect(read.result.isError).toBe(true); - expect(read.result.content[0]?.text).toContain("nothing was read"); + const read = vi.spyOn(f.session, "read"); + const remote = vi.spyOn(f.session, "readRemote"); + for (const name of RETIRED_OWNER_TOOLS) { + expect(names).not.toContain(name); + const reply = await f.call(name, { channel: "private" }); + expect(reply.result.isError).toBe(true); + expect(reply.result.content[0]?.text).toContain(`No tool ${name}`); + } + // Background sync may read; none of it reads the owner-only channel. + expect( + JSON.stringify([...read.mock.calls, ...remote.mock.calls]), + ).not.toContain('"private"'); } finally { await f.cleanup(); vi.unstubAllEnvs(); @@ -17179,93 +17193,79 @@ describe("Buzz settings", () => { ); }); -describe("Owner tools", () => { - const root = "e".repeat(64); - /** The agent's handle from a run. */ - async function running(f: Awaited>) { - await f.service.create({ type: "example/echo", name: "Echo" }); - await vi.waitFor(() => expect(f.native.claim).toHaveBeenCalledWith(bot, 0)); - await settle(); - const handle = runnerGate(); - f.run.mockImplementationOnce(async ({ agent }) => { - handle.resolve(agent); - }); - f.plugin.agents.wake(bot, { channel, root, text: "hi" }); - return handle.promise; - } - const list = (agent: AgentHandle) => - callSessionTool( - appClient(agent, { - read: vi.fn(), - memories: async () => ({ entries: [], partial: false }), - }), - {}, - "owner_channel_list", - {}, - ); - - it("read the owner's channels as the owner, only on the run's handle", async () => { +describe("Retired owner-read tools", () => { + it("are refused on a bundled harness's run before any owner read, while the agent's own reads work", async () => { + // The retired build flag no longer enables anything. vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", "https://relay.example.test"); const f = await setup({ revision: "bundled" }); try { - const agent = await running(f); - expect(f.plugin.agents.handle(bot)).not.toHaveProperty("ownerAccess"); - const answer = JSON.parse(String(await list(agent))); - expect(answer.executedAs).toEqual({ - agent: bot, - owner: viewer, - community: "https://relay.example.test", - }); - // The agent is not in `private`; its owner is, in a role the list - // does not say. - expect(answer.channels).toContainEqual( - expect.objectContaining({ id: "private", role: "unknown", members: 1 }), + await f.service.create({ type: "example/echo", name: "Echo" }); + await vi.waitFor(() => + expect(f.native.claim).toHaveBeenCalledWith(bot, 0), ); - expect(answer.partial).toBe(false); - - f.signIn(other); - await expect(list(agent)).rejects.toThrow("not connected"); - f.connect(false); - await expect(list(agent)).rejects.toThrow("not connected"); - } finally { - await f.ctx.fiber.dispose(); - vi.unstubAllEnvs(); - } - }); - - it("are not given to an installed harness plugin's runs", async () => { - vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", "https://relay.example.test"); - const f = await setup(); - try { - const agent = await running(f); + await settle(); + const run = runnerGate(); + f.run.mockImplementationOnce(async ({ agent }) => { + run.resolve(agent); + }); + f.plugin.agents.wake(bot, { channel, root: "e".repeat(64), text: "hi" }); + const agent = await run.promise; expect(agent).not.toHaveProperty("ownerAccess"); - await expect(list(agent)).rejects.toThrow("nothing was read"); + const client = appClient(agent, { + read: vi.fn(), + memories: async () => ({ entries: [], partial: false }), + }); + // The owner is signed in here, and is in `private`; the agent is not. + // The owner's session would return its messages; the relay gives the + // agent's key none. + const names = (filters: unknown) => + JSON.stringify(filters).includes('"private"'); + const secret = [ + event("s", { content: "OWNER_ONLY", tags: [["h", "private"]] }), + ]; + const ownerRead = f.session.read.bind(f.session); + const ownerRemote = f.session.readRemote.bind(f.session); + const read = vi + .spyOn(f.session, "read") + .mockImplementation(async (filters, options) => + names(filters) ? secret : ownerRead(filters, options), + ); + const remote = vi + .spyOn(f.session, "readRemote") + .mockImplementation(async (filters, options) => + names(filters) ? secret : ownerRemote(filters, options), + ); + f.native.query.mockClear(); + f.native.query.mockImplementation(async (_agent, filters) => + names(filters) ? [] : [event("a", { content: "AGENT_VISIBLE" })], + ); + for (const name of RETIRED_OWNER_TOOLS) + await expect( + callSessionTool(client, {}, name, { channel: "private" }), + ).rejects.toThrow(`No tool ${name}`); + expect(f.native.query).not.toHaveBeenCalled(); + // An ordinary read of `private` runs as the agent and gets nothing. + const denied = await callSessionTool(client, {}, "read", { + channel: "private", + }); + expect(denied).toBe("No messages."); + expect(f.native.query).toHaveBeenCalledWith(expect.anything(), [ + expect.objectContaining({ "#h": ["private"] }), + ]); + await expect( + callSessionTool(client, {}, "read", { channel }), + ).resolves.toContain("AGENT_VISIBLE"); + // Background sync may read; none of it reads the owner-only channel. + expect( + [...read.mock.calls, ...remote.mock.calls].some(([filters]) => + names(filters), + ), + ).toBe(false); } finally { await f.ctx.fiber.dispose(); vi.unstubAllEnvs(); } }); - - it.each([ - "", - "https://other.example.test", - "https://relay.example.test.evil.test", - "https://relay.example.test:8443", - ])( - "are not offered outside the enabled communities (%s)", - async (enabled) => { - vi.stubEnv("VITE_BUZZ_OWNER_MCP_COMMUNITIES", enabled); - const f = await setup({ revision: "bundled" }); - try { - const agent = await running(f); - expect(agent).not.toHaveProperty("ownerAccess"); - await expect(list(agent)).rejects.toThrow("nothing was read"); - } finally { - await f.ctx.fiber.dispose(); - vi.unstubAllEnvs(); - } - }, - ); }); it("tells a transcript only of an agent's new or changed steps", async () => { diff --git a/src/features/agents/service.ts b/src/features/agents/service.ts index 2a4d345de6..37fd27176a 100644 --- a/src/features/agents/service.ts +++ b/src/features/agents/service.ts @@ -14,7 +14,6 @@ import { respond } from "../../buzz-mcp/rpc"; import { respondExternal } from "../../buzz-mcp/external"; import type { ExternalScope } from "./external-connection"; import { externalMcpEnabled } from "./external-connection"; -import { createOwnerAccess, ownerMcpEnabled } from "./owner-access"; import { connectMcp } from "../../buzz-mcp/connection"; import { type HarnessKit, harnessKit } from "../agent-harness/kit"; import { repositoriesContext } from "../communities/repositories"; @@ -194,7 +193,6 @@ import { import { readInterrupted, writeInterrupted } from "./interrupted"; import { AttentionSources, type AttentionProducer } from "./attention-sources"; import { type AddedTool, BUZZ_SESSION } from "../../buzz-mcp/session-tools"; -import type { OwnerAccess } from "../../buzz-mcp/client"; import { nameSoloChat } from "./solo-chat-name"; import { visibleTeams } from "./visible-teams"; import { @@ -532,9 +530,6 @@ export type AgentHandle = Readonly<{ tabs(): ReturnType | undefined; /** On a run's handle only: Buzz settings on this device. */ settings?: DeviceSettings; - /** On a bundled harness's run handle only, where the build enables owner - * tools for its community: reads with the owner's access. */ - ownerAccess?: OwnerAccess; /** Takes notes for this conversation, oldest first. A thread session passes * its root; a channel session omits it and takes all notes in the channel. */ notes?(channelId: string, root?: string): readonly string[]; @@ -2460,22 +2455,6 @@ export class AgentsService extends Service implements Agents { return session && channelTabs(session, ctx.navigation); }, settings: () => this.deviceSettings, - ownerAccess: (agent) => - ownerMcpEnabled(agent.relay) - ? createOwnerAccess(agent, () => { - const binding = this.bindingOf(agent); - return binding && - ownerMcpEnabled(binding.origin) && - this.communities.snapshot().viewer === agent.owner - ? { - viewer: binding.viewer, - origin: binding.origin, - session: binding.session, - signal: binding.controller.signal, - } - : undefined; - }) - : undefined, classifier: () => this.classifier(), decisions: () => this.decisions, context: (agent, event) => this.context(agent, event), @@ -2724,7 +2703,7 @@ export class AgentsService extends Service implements Agents { if ( !/^[a-z][a-z0-9_]{0,63}$/.test(tool.name ?? "") || BUZZ_SESSION.tools.some((core) => core.name === tool.name) || - // The owner_ prefix is reserved for tools that run with the owner's access. + // Reserved so a retired owner_ tool name can never reach plugin code. tool.name.startsWith("owner_") || !tool.description || tool.inputSchema?.type !== "object" || @@ -3167,10 +3146,9 @@ export class AgentsService extends Service implements Agents { const run = this.runners.handle(key, undefined, true); if (!run) throw new Error("This agent is not running in this copy of Buzz."); - // External apps never get the owner's access a bundled harness's run has. - const { handoff, ownerAccess: _, ...agentRun } = run; + const { handoff } = run; const handle: AgentHandle = { - ...agentRun, + ...run, ...(handoff && { handoff: ({ from: _, ...wake }: AgentHandoff) => handoff({ ...wake, from: { external: true } }), diff --git a/vite.config.ts b/vite.config.ts index 16fc8c65c7..05964537de 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -108,11 +108,6 @@ export default defineConfig(async ({ command, mode }) => { .filter(Boolean) .map(relayOrigin) .join(" "); - const ownerMcpCommunities = (env.BUZZ_OWNER_MCP_COMMUNITIES ?? "") - .split(/\s+/) - .filter(Boolean) - .map(relayOrigin) - .join(" "); const workflowRestrictedCommunities = ( env.BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES ?? "" ) @@ -206,8 +201,6 @@ export default defineConfig(async ({ command, mode }) => { "import.meta.env.VITE_BUZZ_EXTERNAL_MCP_COMMUNITIES": JSON.stringify( externalMcpCommunities, ), - "import.meta.env.VITE_BUZZ_OWNER_MCP_COMMUNITIES": - JSON.stringify(ownerMcpCommunities), "import.meta.env.VITE_BUZZ_WORKFLOW_RESTRICTED_COMMUNITIES": JSON.stringify(workflowRestrictedCommunities), "import.meta.env.VITE_BUZZ_BUILDERLAB_URL": JSON.stringify(builderlabUrl),