Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ servers do not support these operations.

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
`owner_member_list`, `owner_read`, `owner_canvas_get` and `owner_canvas_history`,
and the channel writes below. The reads 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
Expand All @@ -91,7 +91,7 @@ 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
when they fill it, the answer says `edits_limited` and some messages may show earlier text. The reads 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
Expand All @@ -108,6 +108,26 @@ 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).

`owner_channel_create`, `owner_channel_update`, `owner_channel_archive`,
`owner_channel_unarchive` and `owner_channel_delete` manage the owner's
ordinary channels and Solo chats through the same session and the app's own
channel writers, never DMs or system channels. A created channel or Solo chat
has the owner as Owner and does not add the agent. Create takes a UUID the agent
chooses, and the same ID with other details is a conflict. To resume a create,
the agent repeats it with the `operation` it answered; that call never signs
another create, and answers `unconfirmed` when Buzz no longer holds that
operation and the relay shows no such channel. Update takes the
`version` that `owner_channel_get` answered and saves nothing if the channel has
changed since. Lifecycle tools take an exact channel ID; delete is permanent.
Each answer's `outcome` is `confirmed` (read back from the relay), `rejected`
(nothing changed; `reason` says why, such as `busy`, `conflict` or
`cancelled`) or `unconfirmed` (it may have taken effect; check with
`owner_channel_get` before trying again). A write is admitted only while the
calling turn runs, the agent still runs here as the owner's, and the owner's
account, community and connection are unchanged, checked again just before the
command is signed and sent. A harness that cannot report whether its turn is
still running gets no writes.

## External app authorization

The build flag `BUZZ_EXTERNAL_MCP_COMMUNITIES` lists the allowed community
Expand Down
6 changes: 6 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -983,6 +983,12 @@ confirmation. **Delete team** asks first; it doesn't delete the team's agents.
instructions for watched turns in both Chat and Agent mode. Watched messages
are context for that Interest; later direct requests do not inherit it. Mesh
agents respond only to their owner.
- **Owner tools**: in communities a build enables them, an agent running in this
app on a bundled harness can read its owner's channels with the owner's access
and create, edit, archive, unarchive or delete the owner's channels and Solo
chats. The owner stays the channel's Owner and the agent is not added. Each
change answers confirmed, rejected or unconfirmed; unconfirmed may still have
taken effect, so check the channel before asking again. Deleting is permanent.
- **Solo workers**: where a plugin offers it, an owner can authorize one agent to
start, message, read, stop, and archive the owner's workers in Solo chats. The
agent asks the owner's Buzz in a private channel with only the two of them;
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ 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_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, and create and manage the owner's channels, 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. |
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/codex/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7078,6 +7078,11 @@ it.each([true, false])(
"owner_read",
"owner_canvas_get",
"owner_canvas_history",
"owner_channel_create",
"owner_channel_update",
"owner_channel_archive",
"owner_channel_unarchive",
"owner_channel_delete",
].map((name) => expect.objectContaining({ name, deferLoading: true }))
: [],
);
Expand Down
67 changes: 67 additions & 0 deletions src/buzz-mcp/app-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -455,3 +455,70 @@ it("offers the handle's owner reads with the turn's signal, withholding answers
expect(() => client.ownerAccess?.held(execution)).toThrow("ended");
expect(ownerAccess.held).toHaveBeenCalledOnce();
});

it("gives owner writes the turn's own caller check, and none to a harness without one", async () => {
const memories = async () => ({ entries: [], partial: false });
const answer = { outcome: "confirmed" } as never;
const ownerAccess = {
createChannel: vi.fn(async (..._: unknown[]) => answer),
updateChannel: vi.fn(async (..._: unknown[]) => answer),
changeChannel: vi.fn(async (..._: unknown[]) => answer),
};
let ended = false;
let protectedOut = false;
const controller = new AbortController();
const handle = {
pubkey: "agent",
ownerAccess,
protection: {
assertAvailable: () => {
if (protectedOut) throw new Error("Protection is unavailable");
},
},
} as unknown as AgentHandle;
const client = appClient(handle, {
read: vi.fn(),
memories,
signal: controller.signal,
check: () => {
if (ended) throw new Error("The turn has ended");
},
});
const input = { id: "c", name: "plans", visibility: "open" as const };
await expect(client.ownerAccess?.createChannel(input)).resolves.toBe(answer);
const [, caller] = ownerAccess.createChannel.mock.calls[0] as unknown as [
unknown,
{ signal?: AbortSignal; check(): void },
];
expect(caller.signal).toBe(controller.signal);
expect(() => caller.check()).not.toThrow();
// The caller is the turn itself: it fails once the turn, or its protection,
// no longer holds.
protectedOut = true;
expect(() => caller.check()).toThrow("Protection is unavailable");
protectedOut = false;
ended = true;
expect(() => caller.check()).toThrow("ended");
await expect(
client.ownerAccess?.changeChannel("archive", "c"),
).rejects.toThrow("ended");
expect(ownerAccess.changeChannel).not.toHaveBeenCalled();
ended = false;
await client.ownerAccess?.updateChannel({ id: "c", version: "v" });
await client.ownerAccess?.changeChannel("delete", "c");
expect(ownerAccess.updateChannel.mock.calls[0]?.[0]).toEqual({
id: "c",
version: "v",
});
expect(ownerAccess.changeChannel.mock.calls[0]?.slice(0, 2)).toEqual([
"delete",
"c",
]);

// A harness that can't say whether its turn still runs changes nothing.
const blind = appClient(handle, { read: vi.fn(), memories });
await expect(blind.ownerAccess?.createChannel(input)).rejects.toThrow(
"unavailable in this harness",
);
expect(ownerAccess.createChannel).toHaveBeenCalledOnce();
});
26 changes: 25 additions & 1 deletion src/buzz-mcp/app-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// reads through its Agents handle, files are read as its plugin reads them, and
// memory is read back through the owner's own session, to whom it is
// encrypted.
import type { BuzzClient, Memories } from "./client";
import type { BuzzClient, Memories, OwnerMutationCaller } from "./client";
import { prepareAttachment } from "../features/messages/prepare-attachment";
import type { AgentHandle, AgentHandoff } from "../features/agents/service";
import type {
Expand Down Expand Up @@ -55,6 +55,24 @@ export function appClient(
check();
return result;
};
/** An owner write, admitted only while this turn holds. A host that can't
* say whether its turn still runs gets none. */
const changing = async <T>(
write: (caller: OwnerMutationCaller) => Promise<T>,
) => {
check();
if (!options.signal && !options.check)
throw new Error(
"Owner changes are unavailable in this harness; nothing was changed.",
);
return write({
signal: options.signal,
check: () => {
check();
agent.protection?.assertAvailable();
},
});
};
return {
...(agent.protection
? {
Expand Down Expand Up @@ -106,6 +124,12 @@ export function appClient(
check();
ownerAccess.held(execution);
},
createChannel: (input) =>
changing((caller) => ownerAccess.createChannel(input, caller)),
updateChannel: (input) =>
changing((caller) => ownerAccess.updateChannel(input, caller)),
changeChannel: (action, id) =>
changing((caller) => ownerAccess.changeChannel(action, id, caller)),
},
}),
relayAuthor: options.relayAuthor,
Expand Down
68 changes: 68 additions & 0 deletions src/buzz-mcp/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,13 @@ export type OwnerChannelDetails = Readonly<{
ttlSeconds?: number;
creator?: string;
createdAt?: number;
/** The metadata record read; owner_channel_update takes it. */
version?: string;
}>;
/** From the relay-signed admin list and roster, read for this call. The
* relay still authorizes every command. */
capabilities: Readonly<{
edit?: boolean;
manageRoles: boolean;
removeMembers: boolean;
archive: boolean;
Expand Down Expand Up @@ -222,6 +225,71 @@ export type OwnerAccess = Readonly<{
/** Throws unless the binding that answered with `execution` still holds,
* as checked just before its answer is released; reads nothing. */
held(execution: OwnerExecution): void;
/** Owner writes. The host supplies `caller`; a call without one changes
* nothing. */
createChannel(
input: OwnerChannelCreate,
caller?: OwnerMutationCaller,
): Promise<OwnerChannelWrite>;
updateChannel(
input: OwnerChannelUpdate,
caller?: OwnerMutationCaller,
): Promise<OwnerChannelWrite>;
changeChannel(
action: OwnerLifecycleAction,
id: string,
caller?: OwnerMutationCaller,
): Promise<OwnerChannelWrite>;
}>;

/** The calling turn, as its host checks it: `check` throws once the turn has
* ended or been stopped. Host-supplied; no tool argument names it. */
export type OwnerMutationCaller = Readonly<{
signal?: AbortSignal | undefined;
check(): void;
}>;
/** `confirmed`: read back from the relay. `rejected`: refused before it was
* sent, or refused by the relay, so nothing changed. `unconfirmed`: sent, or
* possibly sent, with no answer yet; check before acting again. */
export type OwnerWriteOutcome = "confirmed" | "rejected" | "unconfirmed";
export type OwnerLifecycleAction = "archive" | "unarchive" | "delete";
export type OwnerChannelCreate = Readonly<{
/** The caller's own UUID for the new channel. */
id: string;
/** The `operation` an earlier create of `id` answered. Given, the call only
* resumes or confirms that create and never signs another. */
operation?: string | undefined;
name: string;
visibility: "open" | "private";
description?: string | undefined;
ttlSeconds?: number | undefined;
/** A private Solo chat whose only member is the owner. */
solo?: boolean | undefined;
}>;
export type OwnerChannelUpdate = Readonly<{
id: string;
/** The `version` owner_channel_get answered; a newer one is a conflict. */
version: string;
name?: string | undefined;
description?: string | undefined;
visibility?: "public" | "private" | undefined;
joinRole?: "member" | "guest" | undefined;
/** `null` makes a temporary channel ongoing. */
ttlSeconds?: number | null | undefined;
}>;
export type OwnerChannelWrite = Readonly<{
execution: OwnerExecution;
action: "create" | "update" | OwnerLifecycleAction;
channel: string;
outcome: OwnerWriteOutcome;
/** The signed command's event ID, where the writer exposes it. */
operation?: string;
reason?: "busy" | "conflict" | "cancelled" | "denied" | "refused" | "failed";
error?: string;
/** From the roster read back after a confirmed change. */
ownerRole?: OwnerRole | "unknown" | null;
callerMember?: boolean;
version?: string;
}>;

export interface BuzzClient {
Expand Down
Loading
Loading