From 3588c7028b99b876b2692cd7ce04abcd2abb1a7a Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sat, 27 Jun 2026 12:15:51 -0700 Subject: [PATCH] feat(permissions): wire actor permission requests and warn on missing handler - Broadcast `permissionRequest` events per session and add a `respondPermission` action in the actor plugin so the RivetKit client-side approval flow works. - Warn once per session (host-visible, via onAgentStderr) when a tool-permission request arrives with no onPermissionRequest handler registered, instead of a silent deny. - Update the Approvals and crash-course docs and examples to the client-side permissionRequest/respondPermission API; drop the non-functional server-hook auto-approve pattern. --- .../agentos-actor-plugin/src/actions/mod.rs | 20 +- .../src/actions/session.rs | 172 +++++++++++++++++- .../docs/approvals/auto-approve-client.ts | 8 +- examples/docs/approvals/auto-approve.ts | 6 - examples/docs/approvals/selective-client.ts | 11 +- examples/docs/approvals/selective.ts | 9 - .../docs/crash-course/permissions-client.ts | 6 +- .../docs/crash-course/permissions-server.ts | 4 - packages/core/src/agent-os.ts | 81 +++++++++ .../permission-no-handler-warning.test.ts | 164 +++++++++++++++++ website/src/content/docs/docs/approvals.mdx | 105 +++++------ .../src/content/docs/docs/crash-course.mdx | 34 ++-- 12 files changed, 516 insertions(+), 104 deletions(-) create mode 100644 packages/core/tests/permission-no-handler-warning.test.ts diff --git a/crates/agentos-actor-plugin/src/actions/mod.rs b/crates/agentos-actor-plugin/src/actions/mod.rs index 428296d87..3ed1c160f 100644 --- a/crates/agentos-actor-plugin/src/actions/mod.rs +++ b/crates/agentos-actor-plugin/src/actions/mod.rs @@ -39,6 +39,8 @@ pub struct Vars { pub live_sessions: HashMap, /// `live_session_id -> capture pump task`. pub capture_tasks: HashMap>, + /// `live_session_id -> permission-request pump task`. + pub permission_tasks: HashMap>, } impl Vars { @@ -51,12 +53,15 @@ impl Vars { .unwrap_or(external_session_id) } - /// Abort and clear all in-flight capture tasks. Called on VM teardown - /// (sleep / destroy / run-loop exit). + /// Abort and clear all in-flight pump tasks (event capture + permission + /// requests). Called on VM teardown (sleep / destroy / run-loop exit). pub fn clear(&mut self) { for (_, task) in self.capture_tasks.drain() { task.abort(); } + for (_, task) in self.permission_tasks.drain() { + task.abort(); + } self.live_sessions.clear(); } } @@ -336,6 +341,17 @@ pub(crate) async fn dispatch( }, Err(error) => reply_err(host, token, error), }, + "respondPermission" => match decode_as::<(String, String, String)>(args) { + Ok((session_id, permission_id, reply)) => { + match session::respond_permission(vm, vars, &session_id, &permission_id, &reply) + .await + { + Ok(()) => reply_ok(host, token, &()), + Err(error) => reply_err(host, token, error), + } + } + Err(error) => reply_err(host, token, error), + }, "createSignedPreviewUrl" => match decode_as::<(u16, u64)>(args) { Ok((port, ttl_seconds)) => match preview::create(host, port, ttl_seconds).await { Ok(dto) => reply_ok(host, token, &dto), diff --git a/crates/agentos-actor-plugin/src/actions/session.rs b/crates/agentos-actor-plugin/src/actions/session.rs index 538a58d8d..bca1e12b5 100644 --- a/crates/agentos-actor-plugin/src/actions/session.rs +++ b/crates/agentos-actor-plugin/src/actions/session.rs @@ -11,7 +11,7 @@ use std::collections::BTreeMap; use std::time::{SystemTime, UNIX_EPOCH}; use crate::host_ctx::HostCtx; -use agentos_client::{AgentOs, CreateSessionOptions}; +use agentos_client::{AgentOs, CreateSessionOptions, PermissionReply}; use anyhow::{anyhow, Result}; use futures::StreamExt; use serde::{Deserialize, Serialize}; @@ -135,6 +135,111 @@ fn spawn_event_capture( .insert(live_session_id.to_owned(), handle); } +/// Build the `permissionRequest` broadcast body for one request. +/// +/// The RivetKit event wire is CBOR and the body is the array of handler +/// ARGUMENTS the client spreads into the listener (`handler(...body)`). The +/// documented listener is `(data) => …`, so the single argument is the TS +/// `PermissionRequestPayload` — `{ sessionId, request: { permissionId, +/// description?, params } }` — and the body is `[ ]`. The +/// `sessionId` is the client-facing external id (== live for native sessions). +fn permission_event_body( + external_session_id: &str, + permission_id: &str, + description: Option<&str>, + params: &JsonValue, +) -> JsonValue { + json!([{ + "sessionId": external_session_id, + "request": { + "permissionId": permission_id, + "description": description, + "params": params, + }, + }]) +} + +/// Map the wire reply string to a [`PermissionReply`] (`"once"` / `"always"` / +/// `"reject"`), matching the TS `PermissionReply` union. +fn parse_permission_reply(reply: &str) -> Result { + match reply { + "once" => Ok(PermissionReply::Once), + "always" => Ok(PermissionReply::Always), + "reject" => Ok(PermissionReply::Reject), + other => Err(anyhow!( + "invalid permission reply {other:?} (expected \"once\" | \"always\" | \"reject\")" + )), + } +} + +/// Subscribe to the session's permission-request stream and spawn a task that +/// broadcasts each request to connected clients as a `permissionRequest` event +/// (`conn.on("permissionRequest", …)`). +/// +/// Mirrors [`spawn_event_capture`]. A subscriber MUST exist before the guest +/// agent raises a permission request, otherwise the client auto-rejects it +/// (`deliver_sidecar_permission_request` checks `receiver_count() == 0`) — so +/// this is started at session-create time. Clients answer via the +/// `respondPermission` action (→ [`respond_permission`]), which resolves the +/// pending reply slot; this pump only fans the request out, so dropping the +/// broadcast item's responder clone here is harmless. +fn spawn_permission_pump( + ctx: &HostCtx, + vm: &AgentOs, + vars: &mut Vars, + external_session_id: &str, + live_session_id: &str, +) { + let (mut stream, subscription) = match vm.on_permission_request(live_session_id) { + Ok(sub) => sub, + Err(error) => { + tracing::warn!(?error, live_session_id, "on_permission_request subscribe failed"); + return; + } + }; + if let Some(old) = vars.permission_tasks.remove(live_session_id) { + old.abort(); + } + let ctx = ctx.clone(); + let external = external_session_id.to_owned(); + let handle = tokio::spawn(async move { + // Keep the RAII guard alive for the pump's lifetime; dropping the stream + // (on abort / channel close) is the unsubscribe. + let _subscription = subscription; + while let Some(request) = stream.next().await { + let body = permission_event_body( + &external, + &request.permission_id, + request.description.as_deref(), + &request.params, + ); + let mut cbor = Vec::new(); + if ciborium::into_writer(&body, &mut cbor).is_ok() { + let _ = ctx.broadcast(b"permissionRequest".to_vec(), cbor); + } + } + }); + vars.permission_tasks + .insert(live_session_id.to_owned(), handle); +} + +/// Answer a permission request raised by the session's guest agent +/// (`respondPermission`). Resolves the pending reply slot through the client's +/// `respond_permission`, keyed by the live session id. +pub async fn respond_permission( + vm: &AgentOs, + vars: &Vars, + session_id: &str, + permission_id: &str, + reply: &str, +) -> Result<()> { + let reply = parse_permission_reply(reply)?; + let live_session_id = vars.live_id(session_id).to_owned(); + vm.respond_permission(&live_session_id, permission_id, reply) + .await?; + Ok(()) +} + pub async fn create_session( ctx: &HostCtx, vm: &AgentOs, @@ -176,8 +281,11 @@ pub async fn create_session( ) .await?; // At create time `external == live`; capture every `session/update` for this - // session under the external id (spec §3/§5). + // session under the external id (spec §3/§5), and start fanning the guest's + // permission requests out to connected clients. The permission pump must be + // subscribed before the agent runs, or requests would auto-reject. spawn_event_capture(ctx, vm, vars, &session_id, &session_id); + spawn_permission_pump(ctx, vm, vars, &session_id, &session_id); Ok(SessionIdDto { session_id }) } @@ -227,11 +335,14 @@ pub async fn close_session( vars: &mut Vars, session_id: &str, ) -> Result<()> { - // Stop event capture + drop the remap for this external session. + // Stop event capture + the permission pump + drop the remap for this session. let live_session_id = vars.live_id(session_id).to_owned(); if let Some(task) = vars.capture_tasks.remove(&live_session_id) { task.abort(); } + if let Some(task) = vars.permission_tasks.remove(&live_session_id) { + task.abort(); + } vars.live_sessions.remove(session_id); vm.close_session(&live_session_id).map_err(|e| anyhow!(e))?; // Drop persisted metadata + events (explicit, since SQLite FK cascade is @@ -397,3 +508,58 @@ pub async fn resume_session( AcpResumeSessionRequest contract" )) } + +#[cfg(test)] +mod tests { + use super::*; + use agentos_client::PermissionReply; + + #[test] + fn permission_event_body_matches_ts_payload_shape() { + // The TS client listener is `(data) => …` where data is + // `PermissionRequestPayload { sessionId, request: { permissionId, + // description?, params } }`, delivered as the single broadcast arg. + let params = json!({ + "toolCall": { "title": "Bash", "kind": "execute" }, + "options": [{ "optionId": "allow_once" }], + }); + let body = permission_event_body("sess-1", "perm-7", Some("run a command"), ¶ms); + + // Body is the args array spread into the listener: exactly one argument. + let args = body.as_array().expect("body is an array"); + assert_eq!(args.len(), 1, "exactly one handler argument"); + let data = &args[0]; + + assert_eq!(data["sessionId"], json!("sess-1")); + assert_eq!(data["request"]["permissionId"], json!("perm-7")); + assert_eq!(data["request"]["description"], json!("run a command")); + // params are forwarded verbatim so the client can inspect the tool/paths. + assert_eq!(data["request"]["params"], params); + } + + #[test] + fn permission_event_body_serializes_absent_description_as_null() { + let body = permission_event_body("sess-1", "perm-1", None, &json!({})); + assert_eq!(body[0]["request"]["description"], JsonValue::Null); + } + + #[test] + fn parse_permission_reply_maps_each_wire_value() { + assert_eq!(parse_permission_reply("once").unwrap(), PermissionReply::Once); + assert_eq!( + parse_permission_reply("always").unwrap(), + PermissionReply::Always + ); + assert_eq!( + parse_permission_reply("reject").unwrap(), + PermissionReply::Reject + ); + } + + #[test] + fn parse_permission_reply_rejects_unknown_value() { + let err = parse_permission_reply("maybe").unwrap_err().to_string(); + assert!(err.contains("invalid permission reply"), "got: {err}"); + assert!(err.contains("maybe"), "names the bad value: {err}"); + } +} diff --git a/examples/docs/approvals/auto-approve-client.ts b/examples/docs/approvals/auto-approve-client.ts index 255a84e92..6b6297404 100644 --- a/examples/docs/approvals/auto-approve-client.ts +++ b/examples/docs/approvals/auto-approve-client.ts @@ -4,7 +4,13 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// No need to handle permissions on the client. The server hook handles them. +// Auto-approve every request as it arrives. `"always"` also approves future +// requests of the same type, so a multi-step agent run is not interrupted. +const conn = agent.connect(); +conn.on("permissionRequest", async (data) => { + await agent.respondPermission(data.sessionId, data.request.permissionId, "always"); +}); + const session = await agent.createSession("claude", { env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, }); diff --git a/examples/docs/approvals/auto-approve.ts b/examples/docs/approvals/auto-approve.ts index 0fd1f7a89..54ff150e2 100644 --- a/examples/docs/approvals/auto-approve.ts +++ b/examples/docs/approvals/auto-approve.ts @@ -3,12 +3,6 @@ import pi from "./software/pi"; const vm = agentOS({ software: [pi], - // The onPermissionRequest hook runs server-side for every request before it - // is forwarded to clients. Use it to inspect requests in fully automated - // pipelines without a client round-trip. - onPermissionRequest: async (sessionId, request) => { - console.log("auto-approving", sessionId, request.permissionId); - }, }); export const registry = setup({ use: { vm } }); diff --git a/examples/docs/approvals/selective-client.ts b/examples/docs/approvals/selective-client.ts index 49a264183..164ce9cce 100644 --- a/examples/docs/approvals/selective-client.ts +++ b/examples/docs/approvals/selective-client.ts @@ -4,10 +4,17 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// Permission requests forwarded by the server reach the client here. The -// payload is inferred from the actor's event schema, so no cast is needed. const conn = agent.connect(); conn.on("permissionRequest", async (data) => { + // Inspect the request and decide per-request. `request.description` / + // `request.params` carry the raw ACP details (the requested tool, paths, etc.). + const description = data.request.description?.toLowerCase() ?? ""; + if (description.includes("read")) { + // Auto-approve reads. + await agent.respondPermission(data.sessionId, data.request.permissionId, "always"); + return; + } + // Forward everything else to a human. const approved = confirm(`Allow: ${JSON.stringify(data.request)}?`); await agent.respondPermission( data.sessionId, diff --git a/examples/docs/approvals/selective.ts b/examples/docs/approvals/selective.ts index 2f30ec5c4..54ff150e2 100644 --- a/examples/docs/approvals/selective.ts +++ b/examples/docs/approvals/selective.ts @@ -3,15 +3,6 @@ import pi from "./software/pi"; const vm = agentOS({ software: [pi], - onPermissionRequest: async (sessionId, request) => { - // `request.description` and `request.params` carry the raw ACP permission - // details (the requested tool, paths, etc.). Inspect them to decide which - // requests to handle server-side and which to forward to clients. - const description = request.description ?? ""; - if (description.toLowerCase().includes("read")) { - console.log("read request handled server-side", sessionId, request.permissionId); - } - }, }); export const registry = setup({ use: { vm } }); diff --git a/examples/docs/crash-course/permissions-client.ts b/examples/docs/crash-course/permissions-client.ts index 461cebbcb..ff48e7d0e 100644 --- a/examples/docs/crash-course/permissions-client.ts +++ b/examples/docs/crash-course/permissions-client.ts @@ -4,10 +4,12 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// Or handle permissions client-side for human-in-the-loop +// Subscribe and reply to permission requests. Permissions are fail-closed, so +// the agent waits until you reply. const conn = agent.connect(); conn.on("permissionRequest", async (data) => { console.log("Permission requested:", data.request); - // "once" | "always" | "reject" + // "once" | "always" | "reject". Reply "always" to auto-approve trusted + // workloads, or prompt a human for human-in-the-loop. await agent.respondPermission(data.sessionId, data.request.permissionId, "once"); }); diff --git a/examples/docs/crash-course/permissions-server.ts b/examples/docs/crash-course/permissions-server.ts index fae83bf88..54ff150e2 100644 --- a/examples/docs/crash-course/permissions-server.ts +++ b/examples/docs/crash-course/permissions-server.ts @@ -1,12 +1,8 @@ import { agentOS, setup } from "@rivet-dev/agentos"; import pi from "./software/pi"; -// Auto-approve all permissions server-side const vm = agentOS({ software: [pi], - onPermissionRequest: async (sessionId, request) => { - console.log("Auto-approving", sessionId, request.permissionId); - }, }); export const registry = setup({ use: { vm } }); diff --git a/packages/core/src/agent-os.ts b/packages/core/src/agent-os.ts index d9addda9a..096188d32 100644 --- a/packages/core/src/agent-os.ts +++ b/packages/core/src/agent-os.ts @@ -324,6 +324,11 @@ interface AgentSessionEntry { agentInfo: AgentInfo | null; eventHandlers: Set; permissionHandlers: Set; + /** + * Set once we have emitted the "no permission handler registered" warning for + * this session, so a tool-heavy turn does not re-warn on every request. + */ + warnedNoPermissionHandler: boolean; configOverrides: Map; pendingPermissionReplies: Map< string, @@ -922,6 +927,7 @@ function sessionEntryFromInit( agentInfo: initData.agentInfo ?? null, eventHandlers: new Set(), permissionHandlers: new Set(), + warnedNoPermissionHandler: false, configOverrides: new Map(), pendingPermissionReplies: new Map(), }; @@ -3833,6 +3839,73 @@ export class AgentOs { }; } + /** + * Warn once per session (host-visible) that a tool-permission request was + * auto-denied because no `onPermissionRequest` handler is registered. Shared + * by both the bare-callback and JSON-RPC permission paths so the message and + * the once-per-session guard cannot drift between them. + */ + private _warnNoPermissionHandlerOnce( + session: AgentSessionEntry, + params: Record, + ): void { + if (session.warnedNoPermissionHandler) { + return; + } + session.warnedNoPermissionHandler = true; + this._emitSessionWarning( + session, + `agentos: a tool-permission request (${this._permissionToolLabel(params)}) was ` + + `auto-denied because no onPermissionRequest handler is registered for session ` + + `${session.sessionId}. Register one with vm.onPermissionRequest(sessionId, ...) and ` + + `reply via vm.respondPermission(...) to let the agent use tools.`, + ); + } + + /** Best-effort human label for the tool named in a permission request. */ + private _permissionToolLabel(params: Record): string { + if (typeof params.toolName === "string") { + return params.toolName; + } + const toolCall = params.toolCall; + if ( + toolCall && + typeof toolCall === "object" && + typeof (toolCall as { title?: unknown }).title === "string" + ) { + return (toolCall as { title: string }).title; + } + return "a tool"; + } + + /** + * Emit a host-visible warning for a session through the same agent-process log + * channel that surfaces adapter stderr (`onAgentStderr`, default: process + * stderr). Used for agent-os-owned diagnostics — e.g. a permission request + * that was auto-denied because no host hook is registered — so they never fire + * silently inside the sidecar. + */ + private _emitSessionWarning( + session: AgentSessionEntry, + message: string, + ): void { + const handler = this._agentStderrHandler; + if (!handler) { + return; + } + try { + handler({ + sessionId: session.sessionId, + agentType: session.agentType, + processId: session.processId, + pid: session.pid, + chunk: new TextEncoder().encode(`${message}\n`), + }); + } catch { + // A warning sink failure must never affect permission handling. + } + } + private _recordAgentStderr(event: { sessionId: string; agentType: string; @@ -4733,6 +4806,10 @@ export class AgentOs { _acpMethod: request.method, }; if (session.permissionHandlers.size === 0) { + // Default-closed deny; warn once (host-visible) so a forgotten + // onPermissionRequest handler is an observable cause rather than a + // silent denial. See _warnNoPermissionHandlerOnce. + this._warnNoPermissionHandlerOnce(session, permissionParams); return this._buildAcpPermissionResult("reject", permissionParams); } @@ -5153,6 +5230,10 @@ export class AgentOs { } if (session.permissionHandlers.size === 0) { + // Default-closed: deny when no host hook is listening, and warn once + // (host-visible) so a forgotten onPermissionRequest handler is not an + // invisible cause of an agent that cannot use any tool. + this._warnNoPermissionHandlerOnce(session, params); return "reject"; } diff --git a/packages/core/tests/permission-no-handler-warning.test.ts b/packages/core/tests/permission-no-handler-warning.test.ts new file mode 100644 index 000000000..f16d0282a --- /dev/null +++ b/packages/core/tests/permission-no-handler-warning.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, test } from "vitest"; +import type { PermissionReply } from "../src/index.js"; +import { AgentOs } from "../src/index.js"; + +// --------------------------------------------------------------------------- +// #1542 follow-up — when an agent requests a tool permission but the host has +// registered NO `onPermissionRequest` handler, the request is denied +// (default-closed). That is correct, but a host that simply forgot to wire the +// hook would otherwise see only silent denials (and an agent that loops on +// denied tools). We assert the deny still happens AND a host-visible warning is +// emitted once per session through the `onAgentStderr` channel. +// --------------------------------------------------------------------------- + +interface PendingReply { + resolve: (reply: PermissionReply) => void; + reject: (error: Error) => void; + timer: ReturnType; +} + +function injectSession(vm: AgentOs, sessionId: string): void { + const sessions = (vm as unknown as { _sessions: Map }) + ._sessions; + sessions.set(sessionId, { + sessionId, + agentType: "mock", + processId: "", + pid: null, + closed: false, + modes: null, + configOptions: [], + capabilities: {}, + agentInfo: null, + eventHandlers: new Set(), + permissionHandlers: new Set(), + warnedNoPermissionHandler: false, + configOverrides: new Map(), + pendingPermissionReplies: new Map(), + }); +} + +function callPermissionCallback( + vm: AgentOs, + sessionId: string, + permissionId: string, + params: Record, +): Promise { + return ( + vm as unknown as { + _handleAcpPermissionCallback: ( + sessionId: string, + permissionId: string, + params: Record, + ) => Promise; + } + )._handleAcpPermissionCallback(sessionId, permissionId, params); +} + +describe("permission request with no host handler (#1542)", () => { + let vm: AgentOs | null = null; + + afterEach(async () => { + await vm?.dispose(); + vm = null; + }); + + test("denies the tool and warns once per session via onAgentStderr", async () => { + const stderr: string[] = []; + const decoder = new TextDecoder(); + vm = await AgentOs.create({ + onAgentStderr: (event) => { + stderr.push(decoder.decode(event.chunk)); + }, + }); + + injectSession(vm, "session-A"); + + // First denied tool request: rejected + emits exactly one warning. Use the + // real ACP permission param shape — the sidecar forwards the agent's + // `{ toolCall: { title } }`, with no top-level `toolName` — so the label is + // resolved exactly as it is in production. + await expect( + callPermissionCallback(vm, "session-A", "1", { + toolCall: { title: "Bash" }, + }), + ).resolves.toBe("reject"); + + const warnings = () => + stderr.filter((line) => + line.includes("no onPermissionRequest handler is registered"), + ); + expect(warnings()).toHaveLength(1); + // The warning names the tool (from toolCall.title) and the remediation API. + expect(warnings()[0]).toContain("Bash"); + expect(warnings()[0]).toContain("vm.onPermissionRequest"); + expect(warnings()[0]).toContain("session-A"); + + // A second denied request in the same session must NOT re-warn. + await expect( + callPermissionCallback(vm, "session-A", "2", { + toolCall: { title: "Edit" }, + }), + ).resolves.toBe("reject"); + expect(warnings()).toHaveLength(1); + }); + + test("does not warn when a permission handler is registered", async () => { + const stderr: string[] = []; + const decoder = new TextDecoder(); + vm = await AgentOs.create({ + onAgentStderr: (event) => { + stderr.push(decoder.decode(event.chunk)); + }, + }); + + injectSession(vm, "session-A"); + // A registered handler that approves immediately keeps the request off the + // no-handler deny path. + const reg = vm; + reg.onPermissionRequest("session-A", (request) => { + void reg.respondPermission("session-A", request.permissionId, "once"); + }); + + await expect( + callPermissionCallback(vm, "session-A", "1", { + toolCall: { title: "Bash" }, + }), + ).resolves.toBe("once"); + + const warnings = stderr.filter((line) => + line.includes("no onPermissionRequest handler is registered"), + ); + expect(warnings).toHaveLength(0); + // The once-per-session guard must not have been tripped. + const session = ( + vm as unknown as { + _sessions: Map; + } + )._sessions.get("session-A"); + expect(session?.warnedNoPermissionHandler).toBe(false); + }); + + test("a different session warns independently", async () => { + const stderr: string[] = []; + const decoder = new TextDecoder(); + vm = await AgentOs.create({ + onAgentStderr: (event) => { + stderr.push(decoder.decode(event.chunk)); + }, + }); + + injectSession(vm, "session-A"); + injectSession(vm, "session-B"); + + await callPermissionCallback(vm, "session-A", "1", { toolName: "Bash" }); + await callPermissionCallback(vm, "session-B", "1", { toolName: "Bash" }); + + const warnings = stderr.filter((line) => + line.includes("no onPermissionRequest handler is registered"), + ); + expect(warnings).toHaveLength(2); + expect(warnings.some((w) => w.includes("session-A"))).toBe(true); + expect(warnings.some((w) => w.includes("session-B"))).toBe(true); + }); +}); diff --git a/website/src/content/docs/docs/approvals.mdx b/website/src/content/docs/docs/approvals.mdx index 21285a758..6f0006ec6 100644 --- a/website/src/content/docs/docs/approvals.mdx +++ b/website/src/content/docs/docs/approvals.mdx @@ -6,19 +6,19 @@ skill: true import CodeGroup from '@rivet-dev/docs-theme/components/CodeGroup.astro'; -When an agent wants to use a tool (write a file, run a command, etc.), it asks for permission. You approve or deny that request, either interactively or with a server-side hook. +When an agent wants to use a tool (write a file, run a command, etc.), it asks for permission. You approve or deny that request by subscribing to `permissionRequest` and calling `respondPermission`. -- **Human-in-the-loop**: subscribe to `permissionRequest` on the client and respond per-request. -- **Auto-approve**: use the `onPermissionRequest` server hook to decide without a client round-trip. -- **Selective approval**: inspect the request and approve some, forward others to the client. +- **Human-in-the-loop**: subscribe to `permissionRequest` and prompt a human, then respond per-request. +- **Auto-approve**: subscribe to `permissionRequest` and immediately reply `"always"` for trusted workloads. +- **Selective approval**: inspect each request and approve some, reject others. ## Permission request flow -When an agent wants to use a tool, it emits a `permissionRequest`. Every request is delivered to two places at once, and you respond from whichever fits your app: +When an agent wants to use a tool, it emits a `permissionRequest`. Subscribe to it and reply with `respondPermission(sessionId, permissionId, reply)`: -- **On the client**: subscribe to the `permissionRequest` event and call `respondPermission(sessionId, permissionId, reply)`. -- **On the server**: the `onPermissionRequest` hook on the actor runs for every request, with no client round-trip. -- If neither responds, the request blocks until a reply arrives, then rejects after 120 seconds. +- Until you reply, the request stays pending; the agent waits on your decision. +- Permissions are **fail-closed** — a request is **never auto-approved on a timer**. If no reply arrives, it is denied after 120 seconds. +- If you wire **no** responder at all, every tool request is denied immediately and the agent cannot use any tool. The runtime logs a one-time warning per session naming the missing handler, so a forgotten subscription is not a silent failure. ```ts title="client.ts" @@ -54,10 +54,6 @@ import pi from "@agentos-software/pi"; const vm = agentOS({ software: [pi], - // Runs server-side for every permission request, before any client round-trip. - onPermissionRequest: async (sessionId, request) => { - console.log("permission requested:", sessionId, request.permissionId); - }, }); export const registry = setup({ use: { vm } }); @@ -86,31 +82,12 @@ Reply options for `respondPermission`: ### Auto-approve -The `onPermissionRequest` hook runs server-side for every permission request before it reaches any client. Useful for fully automated pipelines. +For fully automated, trusted pipelines, reply `"always"` to every request the moment it arrives. Because permissions are fail-closed, you must reply — there is no auto-grant on a timer. -- **Signature**: `onPermissionRequest: async (sessionId, request) => { ... }`. -- **Inspect**: `request.permissionId`, `request.description`, and `request.params`. -- **Anything not handled** in the hook is forwarded to the client via the `permissionRequest` event. +- **Reply `"always"`** so repeat requests of the same type are approved without re-prompting. +- **Inspect** `request.permissionId`, `request.description`, and `request.params` if you want to log or audit. -```ts title="server.ts" -import { agentOS, setup } from "@rivet-dev/agentos"; -import pi from "@agentos-software/pi"; - -const vm = agentOS({ - software: [pi], - // The onPermissionRequest hook runs server-side for every request before it - // is forwarded to clients. Use it to inspect requests in fully automated - // pipelines without a client round-trip. - onPermissionRequest: async (sessionId, request) => { - console.log("auto-approving", sessionId, request.permissionId); - }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - ```ts title="client.ts" import { createClient } from "@rivet-dev/agentos/client"; import type { registry } from "./server"; @@ -118,42 +95,39 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// No need to handle permissions on the client. The server hook handles them. +// Auto-approve every request as it arrives. `"always"` also approves future +// requests of the same type, so a multi-step agent run is not interrupted. +const conn = agent.connect(); +conn.on("permissionRequest", async (data) => { + await agent.respondPermission(data.sessionId, data.request.permissionId, "always"); +}); + const session = await agent.createSession("claude", { env: { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY! }, }); await agent.sendPrompt(session.sessionId, "Write files as needed"); ``` - - -*[See Full Example](https://github.com/rivet-dev/agent-os/tree/main/examples/docs/approvals)* - -### Selective approval -Inspect the permission request to make approval decisions based on the tool or path. Approve some server-side, forward the rest to the client for human review. - - ```ts title="server.ts" import { agentOS, setup } from "@rivet-dev/agentos"; import pi from "@agentos-software/pi"; const vm = agentOS({ software: [pi], - onPermissionRequest: async (sessionId, request) => { - // `request.description` and `request.params` carry the raw ACP permission - // details (the requested tool, paths, etc.). Inspect them to decide which - // requests to handle server-side and which to forward to clients. - const description = request.description ?? ""; - if (description.toLowerCase().includes("read")) { - console.log("read request handled server-side", sessionId, request.permissionId); - } - }, }); export const registry = setup({ use: { vm } }); registry.start(); ``` + +*[See Full Example](https://github.com/rivet-dev/agent-os/tree/main/examples/docs/approvals)* + +### Selective approval + +Inspect each permission request and decide per-request: auto-approve the safe ones (`request.description` / `request.params` carry the requested tool and paths) and prompt a human for the rest. + + ```ts title="client.ts" import { createClient } from "@rivet-dev/agentos/client"; import type { registry } from "./server"; @@ -161,10 +135,17 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// Permission requests forwarded by the server reach the client here. The -// payload is inferred from the actor's event schema, so no cast is needed. const conn = agent.connect(); conn.on("permissionRequest", async (data) => { + // Inspect the request and decide. `request.description` / `request.params` + // carry the raw ACP details (the requested tool, paths, etc.). + const description = data.request.description?.toLowerCase() ?? ""; + if (description.includes("read")) { + // Auto-approve reads. + await agent.respondPermission(data.sessionId, data.request.permissionId, "always"); + return; + } + // Forward everything else to a human. const approved = confirm(`Allow: ${JSON.stringify(data.request)}?`); await agent.respondPermission( data.sessionId, @@ -178,9 +159,21 @@ const session = await agent.createSession("claude", { }); await agent.sendPrompt(session.sessionId, "Read config.json and update it"); ``` + +```ts title="server.ts" +import { agentOS, setup } from "@rivet-dev/agentos"; +import pi from "@agentos-software/pi"; + +const vm = agentOS({ + software: [pi], +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` *[See Full Example](https://github.com/rivet-dev/agent-os/tree/main/examples/docs/approvals)* -- For interactive applications, subscribe to `permissionRequest` on the client and build an approval UI. -- If neither the server hook nor the client responds, the agent blocks until a response is given or the action times out. +- For interactive applications, subscribe to `permissionRequest` and build an approval UI. +- Permissions are fail-closed: if nothing replies, the request is denied (after 120 seconds if a responder is connected, immediately if none is). diff --git a/website/src/content/docs/docs/crash-course.mdx b/website/src/content/docs/docs/crash-course.mdx index 1e40b9525..4d786d023 100644 --- a/website/src/content/docs/docs/crash-course.mdx +++ b/website/src/content/docs/docs/crash-course.mdx @@ -119,25 +119,9 @@ registry.start(); ### Approvals -Approve or deny agent tool use with human-in-the-loop patterns or auto-approve for trusted workloads. +Approve or deny agent tool use by subscribing to `permissionRequest` and replying. Permissions are fail-closed — nothing runs until you reply. -```ts title="server.ts" -import { agentOS, setup } from "@rivet-dev/agentos"; -import pi from "@agentos-software/pi"; - -// Auto-approve all permissions server-side -const vm = agentOS({ - software: [pi], - onPermissionRequest: async (sessionId, request) => { - console.log("Auto-approving", sessionId, request.permissionId); - }, -}); - -export const registry = setup({ use: { vm } }); -registry.start(); -``` - ```ts title="client.ts" import { createClient } from "@rivet-dev/agentos/client"; import type { registry } from "./server"; @@ -145,14 +129,26 @@ import type { registry } from "./server"; const client = createClient({ endpoint: "http://localhost:6420" }); const agent = client.vm.getOrCreate("my-agent"); -// Or handle permissions client-side for human-in-the-loop const conn = agent.connect(); conn.on("permissionRequest", async (data) => { console.log("Permission requested:", data.request); - // "once" | "always" | "reject" + // "once" | "always" | "reject". Reply "always" to auto-approve trusted + // workloads, or prompt a human for human-in-the-loop. await agent.respondPermission(data.sessionId, data.request.permissionId, "once"); }); ``` + +```ts title="server.ts" +import { agentOS, setup } from "@rivet-dev/agentos"; +import pi from "@agentos-software/pi"; + +const vm = agentOS({ + software: [pi], +}); + +export const registry = setup({ use: { vm } }); +registry.start(); +``` *See [Full Example](https://github.com/rivet-dev/agent-os/tree/main/examples/docs/crash-course) or [Documentation](/docs/approvals)*