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
105 changes: 42 additions & 63 deletions docs/hub.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,69 +25,48 @@ during interactive login or later with `paseo hub permissions grant hub.execute`
created before this split migrate their legacy execution scope to `hub.execute`. Hub sessions cannot
manage their own relationship or permissions.

## Session grants and execution ownership

Trusted clients and the Hub use the same `Session` implementation. The connection boundary supplies
semantic permissions: trusted clients receive the owner permission set, while an enrolled Hub
connection receives its persisted permissions. Legacy relationship files carrying
`hub.execution.*` migrate once to `hub.execute` at the persistence boundary. A denied request returns
the ordinary `rpc_error` shape. See [permissions.md](permissions.md).

The Hub connection still has a narrow lifecycle boundary: it has no trusted-client hello/resume,
browser, binary, retained-session, or broadcast state. Its outbound execution events include only
agents owned by that daemon identity, so unrelated local agents remain outside the Hub surface.

Each Hub create carries an execution ID. The daemon stores that ID with the agent's relationship
owner before acknowledging creation. Duplicate or replayed creates for the same daemon and
execution resolve to the same durable agent. After a lost response, reconnect, or daemon restart,
the Hub retries `hub.execution.agent.create.request` with the same execution ID. The idempotent
response returns the existing agent and its current state; there is no separate reconciliation RPC.
Transient stream frames are not durably replayed.

Daemon restart preserves the Hub relationship and owned execution identity, but interrupts any
active turn. The daemon persists that agent as `closed`; an idempotent create retry returns the same
daemon, execution, and agent identity with that terminal state. Paseo never stores or automatically
replays the original prompt. A duplicate create returns the existing agent without starting another
turn.

Every Hub execution creates a fresh Paseo workspace. The workspace owns the execution's agents and
terminals. Local checkout and worktree targets select only the workspace backing and isolation; the
Hub cannot select or reuse an existing workspace. Hub creates use the same agent creation path as
trusted clients. They may select any worktree target shape and carry optional MCP server configuration and provider-native
`providerOptions` for the agent session. The daemon keeps that configuration in its private agent
record so provider sessions can recover after a restart; neither ordinary client snapshots and
updates nor Hub projections expose session configuration. See [providers.md](providers.md) for the
supported provider keys.

Hub tool preapproval is a private, structured list of `{ kind: "mcp", server, tool }` references.
Every reference must name an MCP server injected by the same create request. The daemon translates
only those identities into the selected provider's native approval configuration. The protocol
cannot name or preapprove native tools such as Bash, Edit, or Write. Explicit local or managed ask
and deny policy takes precedence. Providers without exact MCP preapproval support reject unattended
Hub creation instead of broadening access or waiting for an invisible prompt.
When a create request includes a tool policy, a successful response includes
`toolPolicyApplied: true`; absence of that acknowledgement is not success for unattended execution.

A Hub workflow may use read-only provider settings for classifier steps. This is defense in depth,
not a security boundary: classifier labels and prompt intent do not authorize tools. Exact MCP grants
and the provider's local or managed policy remain the authorization controls.

Execution completion policy remains outside the daemon: a completed agent turn does not imply that
the Hub execution is terminal.

The Hub ends an execution by sending `hub.execution.control.request` with the durable execution ID
and either `interrupt` or `archive`. The daemon resolves the agent from the authenticated daemon
relationship plus that execution ID; callers cannot supply an agent ID or workspace path. Both
actions are idempotent and continue to resolve from stored ownership after daemon restart.
If no execution exists for that authenticated daemon and execution ID, interrupt and archive return
success because the requested stopped or archived state already holds. An execution owned by another
daemon is indistinguishable from a missing execution and is never exposed or affected.

Interrupt uses the ordinary agent cancellation lifecycle. Archive resolves the execution agent's
required workspaceId and sends it through the shared workspace archive service. The service archives
that workspace's agents and terminals, then removes Paseo-owned backing directories only after their
final active workspace reference disappears. Local checkouts remain on disk; sibling workspaces
sharing a backing directory remain active.
## Session grants and agent operations

Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
browser control, and permission management still require their own permissions. See
[permissions.md](permissions.md).

Clients using this contract check `server_info.features.hubAgentRpc` and
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
fall back to creating a fresh agent when continuation was requested.

Hub owns conversation keys, trigger policy, execution records, and the mapping to workspace/agent
IDs. None of these routing concepts are part of the generic daemon RPCs. Creation accepts ordinary
provider configuration, exact MCP tool policy, environment, and workspace/worktree selection.
Private session configuration is persisted for recovery and is not exposed in agent snapshots.
Provider controls remain provider-native; see [providers.md](providers.md).

`create_agent_request.idempotencyKey` identifies one creation operation. With a key, omit
`initialPrompt`, persist the returned agent/workspace identity, then deliver the prompt using
`send_agent_message_request` with a stable `messageId`. Request IDs correlate individual attempts;
creation keys and message IDs identify the operation across attempts. A creation key is daemon-wide;
a message ID is scoped to its agent. Reusing either with different arguments is a conflict.

The daemon journals the assigned agent ID before creation. Concurrent retries share one operation,
and a retry after a lost acknowledgement or restart returns the durable agent without creating a
workspace or starting a turn. Deleting that agent does not make its old creation key reusable.
Confirmed message delivery is also journaled, so retrying its message ID does not submit it again.
An unfinished delivery after restart reports `agent_request_outcome_unknown`: a provider can have
accepted a prompt before the daemon recorded success, so automatic resubmission could duplicate
work. Inspect the agent before choosing a new message ID. Receipts contain identity and request
hashes, never prompts, environment values, or credentials.

Before messaging an archived workspace, call `workspace.recovery.inspect.request`, then
`workspace.recovery.restore.request` and await success. The native message handler unarchives the
agent and loads its persisted provider session. `activeTurnBehavior: "steer"` uses the provider's
native steering behavior, including Paseo's existing behavior when that provider cannot steer.
Execution completion and arrival-specific output authority remain Hub responsibilities.

The older `hub.execution.*` RPCs remain accepted for existing clients. Their execution ownership
and create deduplication behavior are unchanged; new continuation work uses the ordinary RPCs.

## Disconnect and revocation

Expand Down
2 changes: 1 addition & 1 deletion docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
| `automation.manage` | Schedules, heartbeats, and loops |
| `hub.execute` | The Hub-owned execution lifecycle |
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |

Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.

Expand Down
22 changes: 21 additions & 1 deletion packages/client/src/daemon-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,24 @@ test("advertises consumer-provided browser automation capabilities", async () =>
});
});

test("retry-safe creation rejects older hosts before sending any request", async () => {
const transport = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "receipt-gate",
transportFactory: () => transport.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
transport.triggerOpen();
await connecting;
await expect(
client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }),
).rejects.toThrow("Update the host to use retry-safe agent creation.");
expect(transport.sent).toEqual([]);
});

test("Hub management requires daemon support before dispatching requests", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
Expand Down Expand Up @@ -2505,10 +2523,11 @@ test("sends create_agent_request with workspace and caller identity", async () =
clients.push(client);

const connectPromise = client.connect();
mock.triggerOpen();
mock.triggerOpen({ features: { agentRequestReceipts: true } });
await connectPromise;

const createPromise = client.createAgent({
idempotencyKey: "one-creation",
provider: "codex",
cwd: "/tmp/project/.paseo/worktrees/feature-a",
workspaceId: "ws-feature-a",
Expand All @@ -2522,6 +2541,7 @@ test("sends create_agent_request with workspace and caller identity", async () =
expect(request).toEqual(
expect.objectContaining({
type: "create_agent_request",
idempotencyKey: "one-creation",
workspaceId: "ws-feature-a",
callerAgentId: "parent-agent",
}),
Expand Down
10 changes: 10 additions & 0 deletions packages/client/src/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
workspaceId?: string;
callerAgentId?: string;
initialPrompt?: string;
idempotencyKey?: string;
clientMessageId?: string;
outputSchema?: Record<string, unknown>;
images?: CreateAgentRequestMessage["images"];
Expand Down Expand Up @@ -2495,6 +2496,7 @@ export class DaemonClient {
// ============================================================================

async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
if (options.idempotencyKey !== undefined) this.requireAgentRequestReceipts();
const requestId = this.createRequestId(options.requestId);
const config = resolveAgentConfig(options);

Expand All @@ -2506,6 +2508,7 @@ export class DaemonClient {
...(options.workspaceId !== undefined ? { workspaceId: options.workspaceId } : {}),
...(options.callerAgentId !== undefined ? { callerAgentId: options.callerAgentId } : {}),
...(options.initialPrompt ? { initialPrompt: options.initialPrompt } : {}),
idempotencyKey: options.idempotencyKey,
...(options.clientMessageId ? { clientMessageId: options.clientMessageId } : {}),
...(options.outputSchema ? { outputSchema: options.outputSchema } : {}),
...(options.images && options.images.length > 0 ? { images: options.images } : {}),
Expand Down Expand Up @@ -2547,6 +2550,13 @@ export class DaemonClient {
return status.agent;
}

private requireAgentRequestReceipts(): void {
// COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05.
if (this.lastServerInfoMessage?.features?.agentRequestReceipts !== true) {
throw new Error("Update the host to use retry-safe agent creation.");
}
}

async deleteAgent(agentId: string): Promise<void> {
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
Expand Down
2 changes: 2 additions & 0 deletions packages/protocol/src/client-capabilities.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
export const CLIENT_CAPS = {
// Clients outside the Paseo app version series can explicitly accept provider IDs.
allProviders: "all_providers",
// COMPAT(selectiveAgentTimeline): added in v0.1.106. Capable clients receive
// agent streams only for their explicit viewed set. Remove after 2027-01-12
// once the supported client floor is >= v0.1.106.
Expand Down
7 changes: 7 additions & 0 deletions packages/protocol/src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1677,6 +1677,8 @@ export type CreateAgentWorktreeTarget = z.infer<typeof CreateAgentWorktreeTarget

export const CreateAgentRequestMessageSchema = z.object({
type: z.literal("create_agent_request"),
// A creation key requires initialPrompt to be sent separately with a stable messageId.
idempotencyKey: z.string().min(1).max(512).optional(),
config: AgentSessionConfigSchema,
env: z.record(z.string(), z.string()).optional(),
workspaceId: z.string().optional(),
Expand Down Expand Up @@ -3399,6 +3401,10 @@ export const ServerInfoStatusPayloadSchema = z
// COMPAT(providersSnapshot): added in v0.1.48, remove gating when all clients use snapshot
features: z
.object({
// COMPAT(agentRequestReceipts): added in v0.7.3; remove gate after 2027-03-05.
agentRequestReceipts: z.boolean().optional(),
// COMPAT(hubAgentRpc): added in v0.7.3; remove gate after 2027-03-05.
hubAgentRpc: z.boolean().optional(),
providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
Expand Down Expand Up @@ -7076,6 +7082,7 @@ export const WSHelloMessageSchema = z.object({
.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.allProviders]: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.selectiveAgentTimeline]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
Expand Down
3 changes: 2 additions & 1 deletion packages/server/src/server/agent/create-agent/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type EnsureWorkspaceForCreate = (

export interface CreateAgentFromSessionInput {
kind: "session";
agentId?: string;
config: AgentSessionConfig;
workspaceId: string;
worktreeName?: string;
Expand Down Expand Up @@ -181,7 +182,7 @@ export async function createAgentCommand(

const snapshot = await dependencies.agentManager.createAgent(
resolved.config,
undefined,
input.kind === "session" ? input.agentId : undefined,
resolved.createOptions,
);

Expand Down
Loading
Loading