Skip to content
Merged
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
88 changes: 40 additions & 48 deletions docs/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,7 @@ permission. **Settings > External apps** shows the connection and **Revoke acces
Keys stay in Buzz. Closing Settings keeps access active.

Version 1 links contain `version=1`, the requesting `client` display name,
`callback`, random `state`, `scope` (a space-separated selection of `read`, `post`,
and `create`), `code_challenge`,
`callback`, random `state`, `scope` (`agent`), `code_challenge`,
and `code_challenge_method=S256`. The callback must be exactly
`http://127.0.0.1:<port>/buzz/authorize`, with a port from 1024 through 65535.
State and challenge each encode 32 bytes as unpadded base64url. The client name
Expand All @@ -103,52 +102,45 @@ identity change, or the end of the owning agent runtime, plugin, or app also
stops it. A version 1 client needs new setup after its session ends. Version 2
can use saved approval to create another session.

The read scope allows `channel_list`, `channel_get`, and `read`. The post scope
allows `send` and `reply_in_subthread`. The create scope allows only
`channel_create({name})`: a private Solo with the authenticated owner, connected
agent and the owner's account-saved Bestie. Buzz derives these identities and
deduplicates them; the creating agent is an implicit member. A missing, invalid,
unreadable or unavailable saved Bestie fails before creation with a corrective
message. It never substitutes an automatic device fallback. The exact saved key
must be selectable through the session's shared agent choices in this community;
it need not run on this device. Caller-supplied members, identities, visibility,
templates and lifecycle operations are not allowed. Publication and confirmation
use the existing native Solo-create handler and its unconfirmed-result messages.
Buzz classifies each built-in tool through
its trusted external-access policy. Unclassified tools cannot run. Runtime plugin
tools cannot enroll in this policy.

`tools/list` returns all classified tools in one page. It rejects a supplied
`cursor`. Each tool has `_meta["buzz/requiredScopes"]`. The list result has
`_meta["buzz/grantedScopes"]` and `_meta["buzz/supportedScopes"]`; the supported
set is `read`, `post`, and `create`. One authorization can request all three.
Buzz never widens an existing grant: a read/post grant permits creation only after
the owner approves a request that includes `create`. Tool discovery does not grant
access. Each call checks
the grant against the trusted policy. A missing scope returns `isError: true`
and `_meta["buzz/error"]: {code: "authorization_required", required_scopes: [...]}`.
`required_scopes` lists the missing scopes. Each approval replaces the previous
grant. The external app must request all scopes it still needs, including scopes
it already has. For example, an app with `read` that needs `post` requests
`scope=read post`. Use `buzz/grantedScopes` from discovery to retain current access.
The app must not retry the call automatically after approval. New classified tools
use this same contract.

External clients must accept `create` in `buzz/grantedScopes`,
`buzz/supportedScopes` and per-tool `buzz/requiredScopes` before this catalog is
advertised. Older Nexus clients that accept only read/post reject an unknown
supported scope even when their grant still contains only read/post.

The shared schemas apply with explicit
targets: `read` needs `channel`, `thread`, or `message_url`. A `message_url` uses
the linked message's channel and thread; it cannot combine with `channel` or
`thread`. Searches with `q` need `channel` or `message_url`;
`channel_get` needs `channel`; `channel_create` accepts only `name`;
`send` needs `channel`, `text`, and `audience`;
`reply_in_subthread` needs `parent`, `text`, and `audience`. Posts accept
`mentions`, and `send` accepts `reply`. Posts reject `path`, `files`, and `final`.
Unknown fields are rejected. File access, commands, Git credentials, and plugin
tools are unavailable. Connection credentials must stay out of logs and messages.
The only scope Buzz grants is `agent`: full access as the selected agent. The
app lists and calls the same tools that agent's sessions get, with the same
schemas, handler and client authority: the Buzz tools, attention, Solo worker
and plugin tools, the settings tools, and every tool an enabled plugin adds.
A plugin's tools appear and disappear as it is enabled, disabled or removed;
new built-in tools appear without a contract change. The agent's own refusals,
relay permissions, owner confirmations (plugin requests, agent creation and
updates) and plugin isolation apply unchanged; `plugin_disable` acts at once,
as it does for the agent. `self_handoff` wakes the agent as its own does, but
the trigger's `from` is `{ external: true }` and the turn shows an `<app-wake>`
section saying an external app the owner approved sent it, not a message, a
watch or another of the agent's conversations. Memory reads use the agent's
own memory. There is no current conversation, so tools that default to one
need explicit channel, thread or message IDs. File reads (`path`, `files`) go
to the agent type's `readFile`: Claude, Codex, Pi and goose read relative to
the agent's saved workspace on this device, as their sessions do. A Claude or
Codex agent on an execution target refuses, since only a conversation's session
opens that target, and a type without `readFile` refuses as it does natively.
Protected agents cannot connect. Connection credentials must stay out of logs
and messages.

`tools/list` returns the whole catalog in one page and rejects a supplied
`cursor`. Each tool is listed as a session lists it, plus
`_meta["buzz/requiredScopes"]: ["agent"]`. The list result has
`_meta["buzz/grantedScopes"]` (`["agent"]`, or `[]` for an older approval) and
`_meta["buzz/supportedScopes"]: ["agent"]`. A call without full access returns
`isError: true` and
`_meta["buzz/error"]: {code: "authorization_required", required_scopes: ["agent"]}`
before any tool runs. Request `scope=agent`, then call again; never retry
automatically after approval.

Older approvals and links used `read`, `post` and `create`. Buzz never widens
them. A saved older approval still restores and renews, but grants no tools
until the owner approves `scope=agent`. A new link that asks for older scopes
shows an update notice whose only action is Deny, which sends the normal
denial callback. `agent` cannot combine with other scopes. Clients that
accept only `read`, `post` and `create` reject this catalog's supported scopes;
they must request `agent` and accept it in `buzz/grantedScopes`,
`buzz/supportedScopes` and each tool's `buzz/requiredScopes`.

### Durable external approval

Expand Down
35 changes: 20 additions & 15 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,24 +114,29 @@ External apps are available only in communities enabled for this build.
Desktop preview releases enable bb-block only. Other communities cannot grant access.

Start setup in the external app. Buzz desktop shows an **Authorize app**
dialog. Select a community and an agent, check the requested access, then select
**Authorize**. The app completes setup automatically. Keys stay in Buzz.
Only authorize an app when you started its setup. The displayed name comes from
that app. Protected agents cannot use this connection. Keep Buzz open.
An unavailable agent cannot authorize access. Enable its runtime plugin or use
the Buzz copy that runs it.
dialog. Select a community and an agent, then select **Authorize**. The app
gets full access as that agent: every Buzz tool the agent has, including tools
plugins add later, the Nexus workers the agent may run, and changes to Buzz
settings on this device. It gets the agent's Git access to its community's
repositories, can wake the agent in its conversations, and can post any file
your account can read on this device, including outside the agent's workspace;
relative paths start in the workspace. An
agent that runs on an execution target reads files only in a conversation, so
the app cannot send its files. It can disable plugins at once; installing,
updating, enabling or removing one needs your confirmation. The app cannot
assume a current conversation. The app completes setup automatically. Keys stay
in Buzz. Only authorize an app when you started its setup. The displayed name
comes from that app. Protected agents cannot use this connection. Keep Buzz
open. An unavailable agent cannot authorize access. Enable its runtime plugin
or use the Buzz copy that runs it.

Use **Settings → External apps → Revoke access** to stop it. Changing community
or identity also ends access. If a connection ends, start setup in the external
app again. Never put connection credentials in a chat or file.
An app can discover tools that need more access. Start its access request and
approve the requested scopes in Buzz. After approval, make the tool call again.
Each approval replaces prior access. Request all access the app still needs.
Creating Solo chats needs create access. An app can request it with read and
post in one approval; existing read/post access never gains it without your
approval. Buzz includes you, the selected agent and your saved Bestie
automatically. Choose and save an available Bestie in this community first; the
external app cannot choose other members or make the chat public.
Older apps asked for limited read, post or create access, which Buzz no longer
grants. Buzz shows an update notice for such a request; select **Deny**, update
the app and start setup again. A saved limited approval does nothing until you
approve full access.
External posts use the selected agent's identity. Replies and mentions reach
that agent in Buzz; they do not notify the external app through this connection.

Expand Down Expand Up @@ -751,7 +756,7 @@ for another 30 days. Approval expires after 30 days without renewal. Buzz shows
this policy before you authorize access. Expired or revoked approval requires
new owner consent. The app can restore the same access after Buzz or the app
restarts. Keep Buzz open in the approved community. Keys stay in Buzz. Use **Settings > External apps > Revoke access** to remove approval,
even when no session is connected. Added access requires new approval.
even when no session is connected. Full access requires new approval.

### Reconnecting an old agent in a DM

Expand Down
37 changes: 10 additions & 27 deletions src-tauri/src/host_mcp/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,19 +60,10 @@ pub(crate) struct Approval {
}

impl Approval {
// New approvals grant full agent access only. Saved narrow approvals still load and renew, but
// grant no tools, so they never widen without new consent.
fn valid_scopes(&self) -> bool {
!self.scopes.is_empty()
&& self.scopes.len() <= 3
&& self
.scopes
.iter()
.all(|scope| scope == "read" || scope == "post" || scope == "create")
&& self
.scopes
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
== self.scopes.len()
self.scopes == ["agent"]
}
}

Expand Down Expand Up @@ -423,27 +414,19 @@ mod tests {
use super::*;

#[test]
fn approval_scopes_accept_create_without_widening_access() {
fn new_approvals_grant_only_full_agent_access() {
let mut approval = grant().approval;
approval.scopes = vec!["agent".into()];
assert!(approval.valid_scopes());
for scopes in [
vec![],
vec!["read"],
vec!["post"],
vec!["read", "post"],
vec!["create"],
vec!["read", "create"],
vec!["post", "create"],
vec!["create", "post", "read"],
] {
approval.scopes = scopes.iter().map(|scope| (*scope).into()).collect();
assert!(approval.valid_scopes(), "{scopes:?}");
}
for scopes in [
vec![],
vec!["read", "post", "create"],
vec!["agent", "read"],
vec!["agent", "agent"],
vec!["manage"],
vec!["create", "manage"],
vec!["read", "read"],
vec!["create", "create"],
vec!["read", "post", "create", "read"],
] {
approval.scopes = scopes.iter().map(|scope| (*scope).into()).collect();
assert!(!approval.valid_scopes(), "{scopes:?}");
Expand Down
28 changes: 27 additions & 1 deletion src/bundled/agents/ExternalConnectionSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ it("shows an authorized app after settings reopen and lets the owner revoke it",
client: "Test client",
callback: "http://127.0.0.1:1234/buzz/authorize",
state: "A".repeat(43),
scope: ["read"],
scope: ["agent"],
code_challenge: "A".repeat(43),
code_challenge_method: "S256",
},
Expand All @@ -52,3 +52,29 @@ it("shows an authorized app after settings reopen and lets the owner revoke it",
);
await act(async () => closed);
});

it("asks for new setup when a saved approval has only older, limited access", async () => {
const connection = createExternalApps();
connection.bind({
connect: async () => {
throw new Error("not used");
},
deny: async () => {},
pairing: async () => ({
id: "C".repeat(43),
approval: {
client: "Old client",
viewer: "viewer",
agent: "test-agent",
community: "https://example.test",
scopes: ["read", "post"],
},
expires_at: 2_000_000_000,
}),
});
render(<ExternalConnectionSettings connection={connection} />);
expect(
await screen.findByText(/older approval gives limited access/),
).toBeVisible();
expect(screen.getByRole("button", { name: "Revoke access" })).toBeVisible();
});
25 changes: 15 additions & 10 deletions src/bundled/agents/ExternalConnectionSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { useSyncExternalStore } from "react";
import type { ExternalApps } from "../../features/agents/external-connection";
import {
type ExternalApps,
fullAgentAccess,
} from "../../features/agents/external-connection";
import { Header } from "../../shared/design-system/ui/Header";
import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup";
import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow";
Expand Down Expand Up @@ -28,15 +31,17 @@ export function ExternalConnectionSettings({
<PreferenceRow
title={state.grant.client}
subtitle={
state.grant.phase === "connected"
? "Connected"
: state.grant.phase === "waiting"
? "Authorized. Waiting for the app to connect."
: state.grant.phase === "starting"
? "Authorizing…"
: state.pairing
? "Approved. The app can restore this connection."
: "Connection ended. Start setup again in the external app."
state.pairing && !fullAgentAccess(state.pairing.approval.scopes)
? "This older approval gives limited access, which no longer works. Update the app and start setup again to approve full agent access."
: state.grant.phase === "connected"
? "Connected"
: state.grant.phase === "waiting"
? "Authorized. Waiting for the app to connect."
: state.grant.phase === "starting"
? "Authorizing…"
: state.pairing
? "Approved. The app can restore this connection."
: "Connection ended. Start setup again in the external app."
}
trailing={
state.pairing || state.grant.phase !== "ended" ? (
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/claude-code/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export const claudeRun = (
| "timeoutMs"
| "syncedSettings"
| "resumes"
| "readFile"
> => ({
models: (agent) => runtime.models(agent.key, agent.config),
sessionControls: {
Expand All @@ -38,6 +39,7 @@ export const claudeRun = (
await ready?.(turn.signal, turn.delivery.agent);
return runtime.runOnce(turn);
},
readFile: (agent, path, signal) => runtime.workspaceFile(agent, path, signal),
timeoutMs: HANDOVER_MS,
syncedSettings: {
schema: syncedSettingsSchema,
Expand Down
34 changes: 34 additions & 0 deletions src/bundled/claude-code/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1496,6 +1496,40 @@ it("answers Claude's tool calls as the agent, in the thread it is working in", a
});
});

it("reads a file outside its turns in its saved workspace, but not on an execution target", async () => {
const { runtime, fake } = setup();
const agent = {
key: self,
pubkey: self,
config: { ...DEFAULT_CONFIG, workspace: "/saved" },
} as Agent;
const controller = new AbortController();
const read = runtime.workspaceFile(agent, "report.md", controller.signal);
read.catch(() => {});
await flush(10);
const reads = () => fake.processes.filter((process) => process.id === "read");
expect(reads().map((read) => [read.options.args, read.options.cwd])).toEqual([
[["report.md"], "/saved"],
]);
controller.abort();
const remote = new ClaudeRuntime(
{ spawn: fake.spawn } as unknown as Host,
() => undefined,
async () => null,
undefined,
config,
undefined,
undefined,
undefined,
undefined,
async () => ({ provider: "example/box" }),
);
await expect(
remote.workspaceFile(agent, "report.md", new AbortController().signal),
).rejects.toThrow("only in a conversation on its execution target");
expect(reads()).toHaveLength(1);
});

it("reads a busy turn's files from the workspace it started in", async () => {
const mention = message("2", "@Claude write a report", {
tags: [
Expand Down
14 changes: 14 additions & 0 deletions src/bundled/claude-code/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,20 @@ export class ClaudeRuntime {
if (remote) this.confirmedRemote.add(agent);
return remote;
}
/** A file as the agent's processes read one here: in its saved workspace.
* On an execution target only a conversation's session can read it. */
async workspaceFile(agent: Agent, path: string, signal: AbortSignal) {
if (await this.isRemote(agent.key))
throw new Error(
`This agent reads files only in a conversation on its execution target, not ${path}`,
);
return readFile(
this.spawn,
this.resolveConfig(agent.config).workspace,
path,
signal,
);
}

/** The models the agent's next turn can run on, with their effort levels.
* Without a pick it uses its own: the configured one, or else the one its
Expand Down
2 changes: 2 additions & 0 deletions src/bundled/codex/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export const codexRun = (
| "timeoutMs"
| "syncedSettings"
| "resumes"
| "readFile"
> => ({
models: (agent) => runtime.preview(agent),
retire: (agent) => runtime.retire(agent),
Expand All @@ -43,6 +44,7 @@ export const codexRun = (
await ready?.(turn.signal, turn.delivery.agent.key);
return runtime.runOnce(turn);
},
readFile: (agent, path, signal) => runtime.workspaceFile(agent, path, signal),
timeoutMs: HANDOVER_MS,
syncedSettings: {
schema: syncedSettingsSchema,
Expand Down
Loading
Loading