diff --git a/cli/src/agent/permissionAdapter.test.ts b/cli/src/agent/permissionAdapter.test.ts index 53a547c6..bf9fb2b7 100644 --- a/cli/src/agent/permissionAdapter.test.ts +++ b/cli/src/agent/permissionAdapter.test.ts @@ -66,6 +66,62 @@ function createHarness() { }; } +function createHarnessWithMode(getPermissionMode: () => 'default' | 'read-only' | 'safe-yolo' | 'yolo') { + let agentState: FakeAgentState = { + requests: {}, + completedRequests: {} + }; + + const rpcHandlers = new Map Promise | unknown>(); + let permissionHandler: ((request: PermissionRequest) => void) | null = null; + const respondCalls: Array<{ + sessionId: string; + request: PermissionRequest; + response: PermissionResponse; + }> = []; + + const session = { + rpcHandlerManager: { + registerHandler(method: string, handler: (params: unknown) => Promise | unknown) { + rpcHandlers.set(method, handler); + } + }, + updateAgentState(handler: (state: FakeAgentState) => FakeAgentState) { + agentState = handler(agentState); + } + } as unknown as ApiSessionClient; + + const backend: AgentBackend = { + async initialize() {}, + async newSession() { + return 'agent-session'; + }, + async prompt() {}, + async cancelPrompt() {}, + async respondToPermission(sessionId, request, response) { + respondCalls.push({ sessionId, request, response }); + }, + onPermissionRequest(handler) { + permissionHandler = handler; + }, + async disconnect() {} + }; + + new PermissionAdapter(session, backend, getPermissionMode); + + return { + rpcHandlers, + respondCalls, + getAgentState: () => agentState, + emitPermissionRequest(request: PermissionRequest) { + if (!permissionHandler) { + throw new Error('Permission handler was not registered'); + } + permissionHandler(request); + } + }; +} + async function flushAsyncWork(): Promise { await Promise.resolve(); await Promise.resolve(); @@ -193,4 +249,109 @@ describe('PermissionAdapter', () => { } }); }); + + it('auto-approves non-title tools once in safe-yolo mode', async () => { + const harness = createHarnessWithMode(() => 'safe-yolo'); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-safe', + toolCallId: 'perm-safe', + title: 'Read' + })); + + await flushAsyncWork(); + + expect(harness.respondCalls).toEqual([ + { + sessionId: 'session-1', + request: expect.objectContaining({ + id: 'perm-safe', + title: 'Read' + }), + response: { outcome: 'selected', optionId: 'allow-once' } + } + ]); + expect(harness.getAgentState().requests).toEqual({}); + expect(harness.getAgentState().completedRequests).toMatchObject({ + 'perm-safe': { + tool: 'Read', + status: 'approved', + decision: 'approved' + } + }); + }); + + it('auto-approves non-title tools for the session in yolo mode', async () => { + const harness = createHarnessWithMode(() => 'yolo'); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-yolo', + toolCallId: 'perm-yolo', + title: 'Read' + })); + + await flushAsyncWork(); + + expect(harness.respondCalls).toEqual([ + { + sessionId: 'session-1', + request: expect.objectContaining({ + id: 'perm-yolo', + title: 'Read' + }), + response: { outcome: 'selected', optionId: 'allow-always' } + } + ]); + expect(harness.getAgentState().requests).toEqual({}); + expect(harness.getAgentState().completedRequests).toMatchObject({ + 'perm-yolo': { + tool: 'Read', + status: 'approved', + decision: 'approved_for_session' + } + }); + }); + + it('auto-approves read-only non-write tools but keeps writes pending', async () => { + const harness = createHarnessWithMode(() => 'read-only'); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-read-only-read', + toolCallId: 'perm-read-only-read', + title: 'Read' + })); + + await flushAsyncWork(); + + expect(harness.respondCalls).toEqual([ + { + sessionId: 'session-1', + request: expect.objectContaining({ + id: 'perm-read-only-read', + title: 'Read' + }), + response: { outcome: 'selected', optionId: 'allow-once' } + } + ]); + expect(harness.getAgentState().completedRequests).toMatchObject({ + 'perm-read-only-read': { + tool: 'Read', + status: 'approved', + decision: 'approved' + } + }); + + harness.emitPermissionRequest(buildRequest({ + id: 'perm-read-only-write', + toolCallId: 'perm-read-only-write', + title: 'Patch' + })); + + expect(harness.respondCalls).toHaveLength(1); + expect(harness.getAgentState().requests).toMatchObject({ + 'perm-read-only-write': { + tool: 'Patch' + } + }); + }); }); diff --git a/cli/src/agent/permissionAdapter.ts b/cli/src/agent/permissionAdapter.ts index 5df8029b..d08ff6e9 100644 --- a/cli/src/agent/permissionAdapter.ts +++ b/cli/src/agent/permissionAdapter.ts @@ -1,5 +1,5 @@ import type { AgentBackend, PermissionRequest, PermissionResponse } from './types'; -import type { AgentState } from '@/api/types'; +import type { AgentState, SessionPermissionMode } from '@/api/types'; import type { ApiSessionClient } from '@/api/apiSession'; import { logger } from '@/ui/logger'; import { deriveToolName } from '@/agent/utils'; @@ -41,7 +41,8 @@ export class PermissionAdapter { constructor( private readonly session: ApiSessionClient, - private readonly backend: AgentBackend + private readonly backend: AgentBackend, + private readonly getPermissionMode?: () => SessionPermissionMode | undefined ) { this.backend.onPermissionRequest((request) => this.handlePermissionRequest(request)); this.session.rpcHandlerManager.registerHandler( @@ -59,7 +60,8 @@ export class PermissionAdapter { rawInput: request.rawInput }); const input = deriveToolInput(request); - const autoDecision = resolveToolAutoApprovalDecision(undefined, toolName, request.toolCallId); + const mode = this.getPermissionMode?.(); + const autoDecision = resolveToolAutoApprovalDecision(mode, toolName, request.toolCallId); if (autoDecision) { void this.autoApproveRequest(request, toolName, input, autoDecision); diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index 898e2e86..3a98a2cf 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -1,4 +1,4 @@ -import type { AgentState } from '@/api/types'; +import type { AgentState, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { hashObject } from '@/utils/deterministicJson'; @@ -12,6 +12,8 @@ import { registerKillSessionHandler } from '@/claude/registerKillSessionHandler' import { bootstrapSession } from '@/agent/sessionFactory'; import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; +import { PermissionModeSchema } from '@hapi/protocol/schemas'; +import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; function emitReadyIfIdle(props: { queueSize: () => number; @@ -28,12 +30,13 @@ function emitReadyIfIdle(props: { export async function runAgentSession(opts: { agentType: string; startedBy?: 'runner' | 'terminal'; + permissionMode?: SessionPermissionMode; }): Promise { const workingDirectory = getInvokedCwd(); const initialState: AgentState = { controlledByUser: false }; - const { session } = await bootstrapSession({ + const { session, sessionInfo } = await bootstrapSession({ flavor: opts.agentType, startedBy: opts.startedBy ?? 'terminal', workingDirectory, @@ -52,10 +55,12 @@ export async function runAgentSession(opts: { messageQueue.push(formattedText, {}); }); + let currentPermissionMode: SessionPermissionMode = opts.permissionMode ?? sessionInfo.permissionMode ?? 'default'; + const backend: AgentBackend = AgentRegistry.create(opts.agentType); await backend.initialize(); - const permissionAdapter = new PermissionAdapter(session, backend); + const permissionAdapter = new PermissionAdapter(session, backend, () => currentPermissionMode); const happyServer = await startHappyServer(session); const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); @@ -77,9 +82,37 @@ export async function runAgentSession(opts: { let shouldExit = false; let waitAbortController: AbortController | null = null; - session.keepAlive(thinking, 'remote'); + const syncKeepAlive = () => { + session.keepAlive(thinking, 'remote', { + permissionMode: currentPermissionMode + }); + }; + + const resolvePermissionMode = (value: unknown): SessionPermissionMode => { + const parsed = PermissionModeSchema.safeParse(value); + if (!parsed.success || !isPermissionModeAllowedForFlavor(parsed.data, opts.agentType)) { + throw new Error('Invalid permission mode'); + } + return parsed.data as SessionPermissionMode; + }; + + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { + if (!payload || typeof payload !== 'object') { + throw new Error('Invalid session config payload'); + } + const config = payload as { permissionMode?: unknown }; + + if (config.permissionMode !== undefined) { + currentPermissionMode = resolvePermissionMode(config.permissionMode); + } + + syncKeepAlive(); + return { applied: { permissionMode: currentPermissionMode } }; + }); + + syncKeepAlive(); const keepAliveInterval = setInterval(() => { - session.keepAlive(thinking, 'remote'); + syncKeepAlive(); }, 2000); const sendReady = () => { @@ -91,7 +124,7 @@ export async function runAgentSession(opts: { await backend.cancelPrompt(agentSessionId); await permissionAdapter.cancelAll('User aborted'); thinking = false; - session.keepAlive(thinking, 'remote'); + syncKeepAlive(); sendReady(); if (waitAbortController) { waitAbortController.abort(); @@ -131,7 +164,7 @@ export async function runAgentSession(opts: { }]; thinking = true; - session.keepAlive(thinking, 'remote'); + syncKeepAlive(); try { await backend.prompt(agentSessionId, promptContent, (message) => { @@ -148,7 +181,7 @@ export async function runAgentSession(opts: { }); } finally { thinking = false; - session.keepAlive(thinking, 'remote'); + syncKeepAlive(); await permissionAdapter.cancelAll('Prompt finished'); emitReadyIfIdle({ queueSize: () => messageQueue.size(),