diff --git a/docs/conversion.mdx b/docs/conversion.mdx index d155ab4..256a9f2 100644 --- a/docs/conversion.mdx +++ b/docs/conversion.mdx @@ -41,6 +41,10 @@ Events / Message Flow | error | SDKResultMessage.error | method=error | type=session.error (or message error) | type=error | +------------------------+------------------------------+--------------------------------------------+-----------------------------------------+----------------------------------+ +Permission status normalization: +- `permission.requested` uses `status=requested`. +- `permission.resolved` uses `status=accept`, `accept_for_session`, or `reject`. + Synthetics +------------------------------+------------------------+--------------------------+--------------------------------------------------------------+ diff --git a/docs/openapi.json b/docs/openapi.json index 2c4444b..15e6e5e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1408,8 +1408,9 @@ "type": "string", "enum": [ "requested", - "approved", - "denied" + "accept", + "accept_for_session", + "reject" ] }, "ProblemDetails": { diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx index 3abc82f..3f6f693 100644 --- a/docs/session-transcript-schema.mdx +++ b/docs/session-transcript-schema.mdx @@ -158,7 +158,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) | Type | Description | Data | |------|-------------|------| | `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` | -| `permission.resolved` | Permission granted or denied | `{ permission_id, action, status, metadata? }` | +| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` | | `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` | | `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` | @@ -168,7 +168,7 @@ Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) |-------|------|-------------| | `permission_id` | string | Identifier for the permission request | | `action` | string | What the agent wants to do | -| `status` | string | `requested`, `approved`, `denied` | +| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` | | `metadata` | any? | Additional context | **QuestionEventData** diff --git a/gigacode/src/main.rs b/gigacode/src/main.rs index 87e93aa..6710c17 100644 --- a/gigacode/src/main.rs +++ b/gigacode/src/main.rs @@ -17,9 +17,19 @@ fn run() -> Result<(), CliError> { no_token: cli.no_token, gigacode: true, }; - let command = cli - .command - .unwrap_or_else(|| Command::Opencode(OpencodeArgs::default())); + let yolo = cli.yolo; + let command = match cli.command { + Some(Command::Opencode(mut args)) => { + args.yolo = args.yolo || yolo; + Command::Opencode(args) + } + Some(other) => other, + None => { + let mut args = OpencodeArgs::default(); + args.yolo = yolo; + Command::Opencode(args) + } + }; if let Err(err) = init_logging(&command) { eprintln!("failed to init logging: {err}"); return Err(err); diff --git a/sdks/typescript/src/generated/openapi.ts b/sdks/typescript/src/generated/openapi.ts index 59eb12c..707ef43 100644 --- a/sdks/typescript/src/generated/openapi.ts +++ b/sdks/typescript/src/generated/openapi.ts @@ -232,7 +232,7 @@ export interface components { reply: components["schemas"]["PermissionReply"]; }; /** @enum {string} */ - PermissionStatus: "requested" | "approved" | "denied"; + PermissionStatus: "requested" | "accept" | "accept_for_session" | "reject"; ProblemDetails: { detail?: string | null; instance?: string | null; diff --git a/server/packages/sandbox-agent/src/cli.rs b/server/packages/sandbox-agent/src/cli.rs index 0f7dc54..9fc38bc 100644 --- a/server/packages/sandbox-agent/src/cli.rs +++ b/server/packages/sandbox-agent/src/cli.rs @@ -68,6 +68,10 @@ pub struct GigacodeCli { #[arg(long, short = 'n', global = true)] pub no_token: bool, + + /// Bypass all permission checks (auto-approve tool calls). + #[arg(long, global = true)] + pub yolo: bool, } #[derive(Subcommand, Debug)] @@ -126,6 +130,10 @@ pub struct OpencodeArgs { #[arg(long)] session_title: Option, + + /// Bypass all permission checks (auto-approve tool calls). + #[arg(long)] + pub yolo: bool, } impl Default for OpencodeArgs { @@ -134,6 +142,7 @@ impl Default for OpencodeArgs { host: DEFAULT_HOST.to_string(), port: DEFAULT_PORT, session_title: None, + yolo: false, } } } @@ -592,13 +601,18 @@ fn run_opencode(cli: &CliConfig, args: &OpencodeArgs) -> Result<(), CliError> { }; write_stderr_line(&format!("\nEXPERIMENTAL: Please report bugs to:\n- GitHub: https://github.com/rivet-dev/sandbox-agent/issues\n- Discord: https://rivet.dev/discord\n\n{name} is powered by:\n- OpenCode (TUI): https://opencode.ai/\n- Sandbox Agent SDK (multi-agent compatibility): https://sandboxagent.dev/\n\n"))?; + let yolo = args.yolo; let token = cli.token.clone(); let base_url = format!("http://{}:{}", args.host, args.port); crate::daemon::ensure_running(cli, &args.host, args.port, token.as_deref())?; - let session_id = - create_opencode_session(&base_url, token.as_deref(), args.session_title.as_deref())?; + let session_id = create_opencode_session( + &base_url, + token.as_deref(), + args.session_title.as_deref(), + yolo, + )?; write_stdout_line(&format!("OpenCode session: {session_id}"))?; let attach_url = format!("{base_url}/opencode"); @@ -807,14 +821,18 @@ fn create_opencode_session( base_url: &str, token: Option<&str>, title: Option<&str>, + yolo: bool, ) -> Result { let client = HttpClient::builder().build()?; let url = format!("{base_url}/opencode/session"); - let body = if let Some(title) = title { + let mut body = if let Some(title) = title { json!({ "title": title }) } else { json!({}) }; + if yolo { + body["permissionMode"] = json!("bypass"); + } let mut request = client.post(&url).json(&body); if let Ok(directory) = std::env::current_dir() { request = request.header( diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 51d1b53..24c9db4 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -115,6 +115,7 @@ struct OpenCodeSessionRecord { created_at: i64, updated_at: i64, share_url: Option, + permission_mode: Option, } impl OpenCodeSessionRecord { @@ -370,6 +371,7 @@ impl OpenCodeState { created_at: now, updated_at: now, share_url: None, + permission_mode: None, }; let value = record.to_value(); sessions.insert(session_id.to_string(), record); @@ -434,13 +436,14 @@ async fn ensure_backing_session( agent: &str, model: Option, variant: Option, + permission_mode: Option, ) -> Result<(), SandboxError> { let model = model.filter(|value| !value.trim().is_empty()); let variant = variant.filter(|value| !value.trim().is_empty()); let request = CreateSessionRequest { agent: agent.to_string(), agent_mode: None, - permission_mode: None, + permission_mode, model: model.clone(), variant: variant.clone(), agent_version: None, @@ -520,6 +523,8 @@ struct OpenCodeCreateSessionRequest { parent_id: Option, #[schema(value_type = String)] permission: Option, + #[serde(alias = "permission_mode")] + permission_mode: Option, } #[derive(Debug, Serialize, Deserialize, ToSchema)] @@ -1936,10 +1941,13 @@ async fn apply_permission_event( .opencode .emit_event(permission_event("permission.asked", &value)); } - PermissionStatus::Approved | PermissionStatus::Denied => { + PermissionStatus::Accept + | PermissionStatus::AcceptForSession + | PermissionStatus::Reject => { let reply = match permission.status { - PermissionStatus::Approved => "once", - PermissionStatus::Denied => "reject", + PermissionStatus::Accept => "once", + PermissionStatus::AcceptForSession => "always", + PermissionStatus::Reject => "reject", PermissionStatus::Requested => "once", }; let event_value = json!({ @@ -2700,9 +2708,7 @@ async fn apply_item_delta( runtime .part_id_by_message .insert(message_id.clone(), part_id.clone()); - runtime - .messages_with_text_deltas - .insert(message_id.clone()); + runtime.messages_with_text_deltas.insert(message_id.clone()); }) .await; } @@ -3354,6 +3360,7 @@ async fn oc_session_create( title: None, parent_id: None, permission: None, + permission_mode: None, }); let directory = state .opencode @@ -3362,6 +3369,7 @@ async fn oc_session_create( let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); let title = body.title.unwrap_or_else(|| format!("Session {}", id)); + let permission_mode = body.permission_mode; let record = OpenCodeSessionRecord { id: id.clone(), slug, @@ -3373,6 +3381,7 @@ async fn oc_session_create( created_at: now, updated_at: now, share_url: None, + permission_mode, }; let session_value = record.to_value(); @@ -3541,6 +3550,12 @@ async fn oc_session_fork( let id = next_id("ses_", &SESSION_COUNTER); let slug = format!("session-{}", id); let title = format!("Fork of {}", session_id); + let parent_permission_mode = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(&session_id) + .and_then(|s| s.permission_mode.clone()) + }; let record = OpenCodeSessionRecord { id: id.clone(), slug, @@ -3552,6 +3567,7 @@ async fn oc_session_fork( created_at: now, updated_at: now, share_url: None, + permission_mode: parent_permission_mode, }; let value = record.to_value(); @@ -3722,12 +3738,20 @@ async fn oc_session_message_create( }) .await; + let session_permission_mode = { + let sessions = state.opencode.sessions.lock().await; + sessions + .get(&session_id) + .and_then(|s| s.permission_mode.clone()) + }; + if let Err(err) = ensure_backing_session( &state, &session_id, &session_agent, backing_model, backing_variant, + session_permission_mode, ) .await { diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index c89e874..7270336 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -417,6 +417,7 @@ struct SessionState { events: Vec, pending_questions: HashMap, pending_permissions: HashMap, + always_allow_actions: HashSet, item_started: HashSet, item_delta_seen: HashSet, item_map: HashMap, @@ -475,6 +476,7 @@ impl SessionState { events: Vec::new(), pending_questions: HashMap::new(), pending_permissions: HashMap::new(), + always_allow_actions: HashSet::new(), item_started: HashSet::new(), item_delta_seen: HashSet::new(), item_map: HashMap::new(), @@ -796,6 +798,18 @@ impl SessionState { self.pending_permissions.remove(permission_id) } + fn remember_permission_allow_for_session(&mut self, action: &str, metadata: &Option) { + for key in permission_cache_keys(action, metadata) { + self.always_allow_actions.insert(key); + } + } + + fn should_auto_approve_permission(&self, action: &str, metadata: &Option) -> bool { + permission_cache_keys(action, metadata) + .iter() + .any(|key| self.always_allow_actions.contains(key)) + } + /// Find and remove a pending permission whose action matches a question tool /// (AskUserQuestion or ExitPlanMode variants). Returns (permission_id, PendingPermission). fn take_question_tool_permission(&mut self) -> Option<(String, PendingPermission)> { @@ -2334,7 +2348,7 @@ impl SessionManager { UniversalEventData::Permission(PermissionEventData { permission_id: perm_id, action: perm.action, - status: PermissionStatus::Approved, + status: PermissionStatus::Accept, metadata: perm.metadata, }), ) @@ -2454,7 +2468,7 @@ impl SessionManager { UniversalEventData::Permission(PermissionEventData { permission_id: perm_id, action: perm.action, - status: PermissionStatus::Denied, + status: PermissionStatus::Reject, metadata: perm.metadata, }), ) @@ -2489,6 +2503,12 @@ impl SessionManager { message: format!("unknown permission id: {permission_id}"), }); } + if matches!(reply_for_status, PermissionReply::Always) { + if let Some(pending) = pending.as_ref() { + session + .remember_permission_allow_for_session(&pending.action, &pending.metadata); + } + } if let Some(err) = session.ended_error() { return Err(err); } @@ -2610,8 +2630,9 @@ impl SessionManager { if let Some(pending) = pending_permission { let status = match reply_for_status { - PermissionReply::Reject => PermissionStatus::Denied, - PermissionReply::Once | PermissionReply::Always => PermissionStatus::Approved, + PermissionReply::Reject => PermissionStatus::Reject, + PermissionReply::Once => PermissionStatus::Accept, + PermissionReply::Always => PermissionStatus::AcceptForSession, }; let resolved = EventConversion::new( UniversalEventType::PermissionResolved, @@ -2910,13 +2931,127 @@ impl SessionManager { session_id: &str, conversions: Vec, ) -> Result, SandboxError> { - let mut sessions = self.sessions.lock().await; - let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { - SandboxError::SessionNotFound { - session_id: session_id.to_string(), + let (events, auto_approvals) = { + let mut sessions = self.sessions.lock().await; + let session = Self::session_mut(&mut sessions, session_id).ok_or_else(|| { + SandboxError::SessionNotFound { + session_id: session_id.to_string(), + } + })?; + let events = session.record_conversions(conversions); + let mut auto_approvals = Vec::new(); + for event in &events { + if event.event_type != UniversalEventType::PermissionRequested { + continue; + } + let UniversalEventData::Permission(data) = &event.data else { + continue; + }; + let cached = session.should_auto_approve_permission(&data.action, &data.metadata); + if session.agent == AgentId::Codex + || is_question_tool_action(&data.action) + || !cached + { + continue; + } + if let Some(pending) = session.take_permission(&data.permission_id) { + auto_approvals.push(( + session.agent, + session.native_session_id.clone(), + session.claude_sender(), + data.permission_id.clone(), + pending, + )); + } } - })?; - Ok(session.record_conversions(conversions)) + (events, auto_approvals) + }; + + for (agent, native_session_id, claude_sender, permission_id, pending) in auto_approvals { + let reply_result = match agent { + AgentId::Opencode => { + let agent_session_id = + native_session_id + .clone() + .ok_or_else(|| SandboxError::InvalidRequest { + message: "missing OpenCode session id".to_string(), + }); + match agent_session_id { + Ok(agent_session_id) => { + self.opencode_permission_reply( + &agent_session_id, + &permission_id, + PermissionReply::Always, + ) + .await + } + Err(err) => Err(err), + } + } + AgentId::Claude => { + let sender = claude_sender.ok_or_else(|| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + }); + match sender { + Ok(sender) => { + let metadata = pending.metadata.as_ref().and_then(Value::as_object); + let updated_input = metadata + .and_then(|map| map.get("input")) + .cloned() + .unwrap_or(Value::Null); + let mut response_map = serde_json::Map::new(); + if !updated_input.is_null() { + response_map.insert("updatedInput".to_string(), updated_input); + } + let line = claude_control_response_line( + &permission_id, + "allow", + Value::Object(response_map), + ); + sender.send(line).map_err(|_| SandboxError::InvalidRequest { + message: "Claude session is not active".to_string(), + }) + } + Err(err) => Err(err), + } + } + _ => Ok(()), + }; + + if let Err(err) = reply_result { + tracing::warn!( + session_id, + permission_id, + ?err, + "failed to auto-approve cached permission" + ); + let mut sessions = self.sessions.lock().await; + if let Some(session) = Self::session_mut(&mut sessions, session_id) { + session + .pending_permissions + .insert(permission_id.clone(), pending.clone()); + } + continue; + } + + let resolved = EventConversion::new( + UniversalEventType::PermissionResolved, + UniversalEventData::Permission(PermissionEventData { + permission_id: permission_id.clone(), + action: pending.action, + status: PermissionStatus::AcceptForSession, + metadata: pending.metadata, + }), + ) + .synthetic() + .with_native_session(native_session_id); + let mut sessions = self.sessions.lock().await; + if let Some(session) = Self::session_mut(&mut sessions, session_id) { + session.record_conversions(vec![resolved]); + } + } + + Ok(events) } async fn parse_claude_line(&self, line: &str, session_id: &str) -> Vec { @@ -5412,6 +5547,60 @@ pub(crate) fn is_question_tool_action(action: &str) -> bool { ) } +fn permission_cache_keys(action: &str, metadata: &Option) -> Vec { + let mut keys = Vec::new(); + push_permission_cache_key(&mut keys, action); + if let Some(metadata) = metadata.as_ref().and_then(Value::as_object) { + if let Some(permission) = metadata.get("permission").and_then(Value::as_str) { + push_permission_cache_key(&mut keys, permission); + } + if let Some(kind) = metadata.get("codexRequestKind").and_then(Value::as_str) { + push_permission_cache_key(&mut keys, kind); + } + if let Some(tool_name) = metadata + .get("toolName") + .or_else(|| metadata.get("tool_name")) + .and_then(Value::as_str) + { + push_permission_cache_key(&mut keys, tool_name); + } + } + keys.sort_unstable(); + keys.dedup(); + keys +} + +fn push_permission_cache_key(keys: &mut Vec, raw: &str) { + let raw = raw.trim(); + if raw.is_empty() { + return; + } + keys.push(raw.to_string()); + if let Some(category) = permission_action_category(raw) { + keys.push(category); + } +} + +fn permission_action_category(action: &str) -> Option { + let first = action.split_whitespace().next().unwrap_or(action); + let stripped = first + .split_once(':') + .map(|(prefix, _)| prefix) + .unwrap_or(first) + .trim(); + if stripped.is_empty() { + return None; + } + if stripped + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.') + { + Some(stripped.to_ascii_lowercase()) + } else { + None + } +} + fn read_lines(reader: R, sender: mpsc::UnboundedSender) { let mut reader = BufReader::new(reader); let mut line = String::new(); diff --git a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts index 0742da7..2b38d3b 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/permissions.test.ts @@ -53,6 +53,37 @@ describe("OpenCode-compatible Permission API", () => { throw new Error("Timed out waiting for permission request"); } + async function waitForCondition( + check: () => boolean | Promise, + timeoutMs = 10_000, + intervalMs = 100, + ) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await check()) { + return; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("Timed out waiting for condition"); + } + + async function waitForValue( + getValue: () => T | undefined | Promise, + timeoutMs = 10_000, + intervalMs = 100, + ): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = await getValue(); + if (value !== undefined) { + return value; + } + await new Promise((r) => setTimeout(r, intervalMs)); + } + throw new Error("Timed out waiting for value"); + } + describe("permission.reply (global)", () => { it("should receive permission.asked and reply via global endpoint", async () => { await client.session.prompt({ @@ -71,6 +102,108 @@ describe("OpenCode-compatible Permission API", () => { }); expect(response.error).toBeUndefined(); }); + + it("should emit permission.replied with always when reply is always", async () => { + const eventStream = await client.event.subscribe(); + const repliedEventPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error("Timed out waiting for permission.replied")), 15_000); + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "permission.replied") { + clearTimeout(timeout); + resolve(event); + break; + } + } + } catch (err) { + clearTimeout(timeout); + reject(err); + } + })(); + }); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const asked = await waitForPermissionRequest(); + const requestId = asked?.id; + expect(requestId).toBeDefined(); + + const response = await client.permission.reply({ + requestID: requestId, + reply: "always", + }); + expect(response.error).toBeUndefined(); + + const replied = await repliedEventPromise; + expect(replied?.properties?.requestID).toBe(requestId); + expect(replied?.properties?.reply).toBe("always"); + }); + + it("should auto-reply subsequent matching permissions after always", async () => { + const eventStream = await client.event.subscribe(); + const repliedEvents: any[] = []; + (async () => { + try { + for await (const event of (eventStream as any).stream) { + if (event.type === "permission.replied") { + repliedEvents.push(event); + } + } + } catch { + // Stream can end during test teardown. + } + })(); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const firstAsked = await waitForPermissionRequest(); + const firstRequestId = firstAsked?.id; + expect(firstRequestId).toBeDefined(); + + const firstReply = await client.permission.reply({ + requestID: firstRequestId, + reply: "always", + }); + expect(firstReply.error).toBeUndefined(); + + await waitForCondition(() => + repliedEvents.some( + (event) => + event?.properties?.requestID === firstRequestId && + event?.properties?.reply === "always", + ), + ); + + await client.session.prompt({ + sessionID: sessionId, + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: permissionPrompt }], + }); + + const autoReplyEvent = await waitForValue(() => + repliedEvents.find( + (event) => + event?.properties?.requestID !== firstRequestId && + event?.properties?.reply === "always", + ), + ); + const autoRequestId = autoReplyEvent?.properties?.requestID; + expect(autoRequestId).toBeDefined(); + + await waitForCondition(async () => { + const list = await client.permission.list(); + return !(list.data ?? []).some((item) => item?.id === autoRequestId); + }); + }); }); describe("postSessionIdPermissionsPermissionId (session)", () => { diff --git a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts index c778691..0c3c8ab 100644 --- a/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts +++ b/server/packages/sandbox-agent/tests/opencode-compat/session.test.ts @@ -20,6 +20,29 @@ describe("OpenCode-compatible Session API", () => { let handle: SandboxAgentHandle; let client: OpencodeClient; + async function createSessionViaHttp(body: Record) { + const response = await fetch(`${handle.baseUrl}/opencode/session`, { + method: "POST", + headers: { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + expect(response.ok).toBe(true); + return response.json(); + } + + async function getBackingSessionPermissionMode(sessionId: string) { + const response = await fetch(`${handle.baseUrl}/v1/sessions`, { + headers: { Authorization: `Bearer ${handle.token}` }, + }); + expect(response.ok).toBe(true); + const data = await response.json(); + const session = (data.sessions ?? []).find((item: any) => item.sessionId === sessionId); + return session?.permissionMode; + } + beforeAll(async () => { // Build the binary if needed await buildSandboxAgent(); @@ -63,6 +86,42 @@ describe("OpenCode-compatible Session API", () => { expect(session1.data?.id).not.toBe(session2.data?.id); }); + + it("should pass permissionMode bypass to backing session", async () => { + const session = await createSessionViaHttp({ permissionMode: "bypass" }); + const sessionId = session.id as string; + expect(sessionId).toBeDefined(); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "hello" }], + }, + }); + expect(prompt.error).toBeUndefined(); + + const permissionMode = await getBackingSessionPermissionMode(sessionId); + expect(permissionMode).toBe("bypass"); + }); + + it("should accept permission_mode alias and pass bypass to backing session", async () => { + const session = await createSessionViaHttp({ permission_mode: "bypass" }); + const sessionId = session.id as string; + expect(sessionId).toBeDefined(); + + const prompt = await client.session.prompt({ + path: { id: sessionId }, + body: { + model: { providerID: "mock", modelID: "mock" }, + parts: [{ type: "text", text: "hello" }], + }, + }); + expect(prompt.error).toBeUndefined(); + + const permissionMode = await getBackingSessionPermissionMode(sessionId); + expect(permissionMode).toBe("bypass"); + }); }); describe("session.list", () => { diff --git a/server/packages/sandbox-agent/tests/sessions/permissions.rs b/server/packages/sandbox-agent/tests/sessions/permissions.rs index a114236..78c6c23 100644 --- a/server/packages/sandbox-agent/tests/sessions/permissions.rs +++ b/server/packages/sandbox-agent/tests/sessions/permissions.rs @@ -80,3 +80,193 @@ async fn permission_flow_snapshots() { } } } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_reply_always_sets_accept_for_session_status() { + let app = TestApp::new(); + install_agent(&app.app, AgentId::Mock).await; + + let session_id = "perm-always-mock"; + create_session(&app.app, AgentId::Mock, session_id, "plan").await; + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "send permission prompt"); + + let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + find_permission_id(events).is_some() || should_stop(events) + }) + .await; + let permission_id = find_permission_id(&events).expect("permission.requested missing"); + + let status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/permissions/{permission_id}/reply"), + Some(json!({ "reply": "always" })), + ) + .await; + assert_eq!(status, StatusCode::NO_CONTENT, "reply permission always"); + + let resolved_events = + poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(permission_id.as_str()) + }) + }) + .await; + + let resolved = resolved_events + .iter() + .rev() + .find(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(permission_id.as_str()) + }) + .expect("permission.resolved missing"); + let status = resolved + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str); + assert_eq!(status, Some("accept_for_session")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn permission_reply_always_auto_approves_subsequent_permissions() { + let app = TestApp::new(); + install_agent(&app.app, AgentId::Mock).await; + + let session_id = "perm-always-auto-mock"; + create_session(&app.app, AgentId::Mock, session_id, "plan").await; + + let first_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!( + first_status, + StatusCode::NO_CONTENT, + "send first permission prompt" + ); + + let first_events = + poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + find_permission_id(events).is_some() || should_stop(events) + }) + .await; + let first_permission_id = + find_permission_id(&first_events).expect("first permission.requested missing"); + + let reply_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/permissions/{first_permission_id}/reply"), + Some(json!({ "reply": "always" })), + ) + .await; + assert_eq!( + reply_status, + StatusCode::NO_CONTENT, + "reply first permission always" + ); + + let second_status = send_status( + &app.app, + Method::POST, + &format!("/v1/sessions/{session_id}/messages"), + Some(json!({ "message": PERMISSION_PROMPT })), + ) + .await; + assert_eq!( + second_status, + StatusCode::NO_CONTENT, + "send second permission prompt" + ); + + let events = poll_events_until_match(&app.app, session_id, Duration::from_secs(30), |events| { + let requested_ids = events + .iter() + .filter_map(|event| { + if event.get("type").and_then(Value::as_str) != Some("permission.requested") { + return None; + } + event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + .collect::>(); + if requested_ids.len() < 2 { + return false; + } + let second_permission_id = &requested_ids[1]; + events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(second_permission_id.as_str()) + && event + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str) + == Some("accept_for_session") + }) + }) + .await; + + let requested_ids = events + .iter() + .filter_map(|event| { + if event.get("type").and_then(Value::as_str) != Some("permission.requested") { + return None; + } + event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + .map(|value| value.to_string()) + }) + .collect::>(); + assert!( + requested_ids.len() >= 2, + "expected at least two permission.requested events" + ); + let second_permission_id = &requested_ids[1]; + + let second_resolved = events.iter().any(|event| { + event.get("type").and_then(Value::as_str) == Some("permission.resolved") + && event + .get("data") + .and_then(|data| data.get("permission_id")) + .and_then(Value::as_str) + == Some(second_permission_id.as_str()) + && event + .get("data") + .and_then(|data| data.get("status")) + .and_then(Value::as_str) + == Some("accept_for_session") + }); + assert!( + second_resolved, + "second permission should auto-resolve as accept_for_session" + ); +} diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new index a6ecd24..d7b322b 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__multi_turn__assert_session_snapshot@multi_turn_mock.snap.new @@ -38,13 +38,36 @@ first: status: in_progress seq: 5 type: item.started - - item: - content_types: [] - kind: message - role: assistant - status: completed + - delta: + delta: "" + item_id: "" + native_item_id: "" seq: 6 - type: item.completed + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 7 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 8 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 9 + type: item.delta + - delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 10 + type: item.delta second: - item: content_types: diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new deleted file mode 100644 index 145c275..0000000 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__permissions__assert_session_snapshot@permission_events_mock.snap.new +++ /dev/null @@ -1,81 +0,0 @@ ---- -source: server/packages/sandbox-agent/tests/sessions/permissions.rs -assertion_line: 12 -expression: value ---- -- metadata: true - seq: 1 - session: started - type: session.started -- item: - content_types: - - text - kind: message - role: user - status: in_progress - seq: 2 - type: item.started -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 3 - type: item.delta -- item: - content_types: - - text - kind: message - role: user - status: completed - seq: 4 - type: item.completed -- item: - content_types: - - text - kind: message - role: assistant - status: in_progress - seq: 5 - type: item.started -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 6 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 7 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 8 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 9 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 10 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 11 - type: item.delta -- delta: - delta: "" - item_id: "" - native_item_id: "" - seq: 12 - type: item.delta diff --git a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new index bc77ae1..0428c57 100644 --- a/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new +++ b/server/packages/sandbox-agent/tests/sessions/snapshots/sessions__sessions__questions__assert_session_snapshot@question_reply_events_mock.snap.new @@ -91,9 +91,47 @@ expression: value native_item_id: "" seq: 14 type: item.delta -- question: - id: "" - options: 4 - status: requested +- delta: + delta: "" + item_id: "" + native_item_id: "" seq: 15 - type: question.requested + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 16 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 17 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 18 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 19 + type: item.delta +- delta: + delta: "" + item_id: "" + native_item_id: "" + seq: 20 + type: item.delta +- item: + content_types: + - text + kind: message + role: assistant + status: completed + seq: 21 + type: item.completed diff --git a/server/packages/universal-agent-schema/src/lib.rs b/server/packages/universal-agent-schema/src/lib.rs index d30d93f..21bdf65 100644 --- a/server/packages/universal-agent-schema/src/lib.rs +++ b/server/packages/universal-agent-schema/src/lib.rs @@ -161,8 +161,9 @@ pub struct PermissionEventData { #[serde(rename_all = "snake_case")] pub enum PermissionStatus { Requested, - Approved, - Denied, + Accept, + AcceptForSession, + Reject, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, ToSchema)] diff --git a/spec/universal-schema.json b/spec/universal-schema.json index e8fd21a..3d6fd89 100644 --- a/spec/universal-schema.json +++ b/spec/universal-schema.json @@ -370,8 +370,9 @@ "type": "string", "enum": [ "requested", - "approved", - "denied" + "accept", + "accept_for_session", + "reject" ] }, "QuestionEventData": {