From 611c4eec49a808f7c4ffdd1ed4d4c5df9d1bb215 Mon Sep 17 00:00:00 2001 From: CoColate Date: Thu, 21 May 2026 17:09:10 +0800 Subject: [PATCH] fix(codex): honor yolo for MCP elicitation (#655) Route Codex app-server MCP elicitation decisions through the live session permission mode. HAPI bridge elicitation remains accepted, non-HAPI elicitation stays cancelled in non-yolo modes, and yolo accepts future non-HAPI prompts after mode changes.\n\nAdds adapter and launcher seam tests covering default -> yolo -> default behavior.\n\nTests:\n- bunx vitest run src/codex/utils/appServerPermissionAdapter.test.ts src/codex/codexRemoteLauncher.test.ts\n- bun run typecheck --- cli/src/codex/codexRemoteLauncher.test.ts | 56 +++++++++++- cli/src/codex/codexRemoteLauncher.ts | 7 +- .../utils/appServerPermissionAdapter.test.ts | 91 +++++++++++++++++++ .../codex/utils/appServerPermissionAdapter.ts | 15 ++- 4 files changed, 162 insertions(+), 7 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index c9db9ec5..28e931a1 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -836,6 +836,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; const collaborationModes: Array = []; + let currentPermissionMode: EnhancedMode['permissionMode'] = mode.permissionMode; let currentModel: string | null | undefined = mode.model; let currentCollaborationMode: EnhancedMode['collaborationMode'] | undefined = mode.collaborationMode; let agentState: FakeAgentState = { @@ -875,7 +876,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat sessionId: null as string | null, thinking: false, getPermissionMode() { - return 'default' as const; + return currentPermissionMode; }, setModel(nextModel: string | null) { currentModel = nextModel; @@ -922,6 +923,9 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat foundSessionIds, resetThreadCalls, rpcHandlers, + setPermissionMode: (nextMode: EnhancedMode['permissionMode']) => { + currentPermissionMode = nextMode; + }, getModel: () => currentModel, getCollaborationMode: () => currentCollaborationMode, collaborationModes, @@ -1021,6 +1025,56 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); + it('uses live permission mode for app-server MCP elicitation handlers', async () => { + const { session, setPermissionMode } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + const handler = harness.requestHandlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + const request = { + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'qmd', + mode: 'form', + message: 'Allow the qmd MCP server to run tool "status"?', + _meta: null, + requestedSchema: { + type: 'object', + properties: { + approval: { + type: 'string', + enum: ['allow', 'deny'] + } + }, + required: ['approval'] + } + }; + + await expect(handler?.(request)).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + + setPermissionMode('yolo'); + await expect(handler?.(request)).resolves.toEqual({ + action: 'accept', + content: { + approval: 'allow' + }, + _meta: null + }); + + setPermissionMode('default'); + await expect(handler?.(request)).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + }); + it('sends Codex plan collaboration mode when the app-server advertises it', async () => { const { session } = createSessionStub(['plan this'], { permissionMode: 'default', diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 5eb64e51..fb69993d 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -505,12 +505,14 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return `${match[1]}.${match[2]}`; }; - const permissionHandler = new CodexPermissionHandler(session.client, () => { + const getCurrentCodexPermissionMode = () => { const mode = session.getPermissionMode(); return mode === 'default' || mode === 'read-only' || mode === 'safe-yolo' || mode === 'yolo' ? mode : undefined; - }, { + }; + + const permissionHandler = new CodexPermissionHandler(session.client, getCurrentCodexPermissionMode, { onRequest: ({ id, toolName, input }) => { if (toolName === 'request_user_input') { session.sendAgentMessage({ @@ -2443,6 +2445,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { registerAppServerPermissionHandlers({ client: appServerClient, permissionHandler, + getPermissionMode: getCurrentCodexPermissionMode, onUserInputRequest: async ({ id, input }) => { try { const answers = await permissionHandler.handleUserInputRequest(id, input); diff --git a/cli/src/codex/utils/appServerPermissionAdapter.test.ts b/cli/src/codex/utils/appServerPermissionAdapter.test.ts index 1c6d866f..7d0df706 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.test.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.test.ts @@ -189,6 +189,97 @@ describe('registerAppServerPermissionHandlers', () => { }); }); + it('accepts non-HAPI MCP elicitation requests when live permission mode is yolo', async () => { + const { client, handlers } = createClient(); + let permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo' = 'default'; + const permissionHandler = { + handleToolCall: vi.fn() + }; + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never, + getPermissionMode: () => permissionMode + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + const request = { + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'qmd', + mode: 'form', + message: 'Allow the qmd MCP server to run tool "status"?', + _meta: null, + requestedSchema: { + type: 'object', + properties: { + approval: { + type: 'string', + enum: ['allow', 'deny'] + } + }, + required: ['approval'] + } + }; + + await expect(handler?.(request)).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + + permissionMode = 'yolo'; + await expect(handler?.(request)).resolves.toEqual({ + action: 'accept', + content: { + approval: 'allow' + }, + _meta: null + }); + + permissionMode = 'default'; + await expect(handler?.(request)).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + }); + + it('does not auto-accept non-HAPI MCP elicitation requests in safe-yolo mode', async () => { + const { client, handlers } = createClient(); + const permissionHandler = { + handleToolCall: vi.fn() + }; + + registerAppServerPermissionHandlers({ + client: client as never, + permissionHandler: permissionHandler as never, + getPermissionMode: () => 'safe-yolo' + }); + + const handler = handlers.get('mcpServer/elicitation/request'); + expect(handler).toBeTypeOf('function'); + + await expect(handler?.({ + threadId: 'thread-1', + turnId: 'turn-1', + serverName: 'external', + mode: 'form', + message: 'Collect data', + _meta: null, + requestedSchema: { + type: 'object', + properties: {}, + } + })).resolves.toEqual({ + action: 'cancel', + content: null, + _meta: null + }); + }); + it('cancels non-HAPI MCP elicitation requests', async () => { const { client, handlers } = createClient(); const permissionHandler = { diff --git a/cli/src/codex/utils/appServerPermissionAdapter.ts b/cli/src/codex/utils/appServerPermissionAdapter.ts index 9e3f2788..b62514c4 100644 --- a/cli/src/codex/utils/appServerPermissionAdapter.ts +++ b/cli/src/codex/utils/appServerPermissionAdapter.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { logger } from '@/ui/logger'; +import type { CodexPermissionMode } from '@hapi/protocol/types'; import type { CodexPermissionHandler } from './permissionHandler'; import type { CodexAppServerClient } from '../codexAppServerClient'; @@ -157,12 +158,13 @@ function isHapiBridgeElicitation(params: unknown): boolean { export function registerAppServerPermissionHandlers(args: { client: CodexAppServerClient; permissionHandler: CodexPermissionHandler; + getPermissionMode?: () => CodexPermissionMode | undefined; onUserInputRequest?: (request: { id: string; input: unknown }) => Promise< | { decision: 'accept'; answers: Record | Record } | { decision: 'decline' | 'cancel' } >; }): void { - const { client, permissionHandler, onUserInputRequest } = args; + const { client, permissionHandler, getPermissionMode, onUserInputRequest } = args; client.registerRequestHandler('item/commandExecution/requestApproval', async (params) => { const record = asRecord(params) ?? {}; @@ -258,11 +260,15 @@ export function registerAppServerPermissionHandlers(args: { client.registerRequestHandler('mcpServer/elicitation/request', async (params) => { const record = asRecord(params) ?? {}; - if (!isHapiBridgeElicitation(params)) { + const currentPermissionMode = getPermissionMode?.(); + const shouldAccept = isHapiBridgeElicitation(params) || currentPermissionMode === 'yolo'; + + if (!shouldAccept) { logger.debug('[CodexAppServer] Cancelling unsupported MCP elicitation request', { serverName: record.serverName, mode: record.mode, - message: record.message + message: record.message, + permissionMode: currentPermissionMode ?? 'unknown' }); return { @@ -275,7 +281,8 @@ export function registerAppServerPermissionHandlers(args: { logger.debug('[CodexAppServer] Accepting MCP elicitation request', { serverName: record.serverName, mode: record.mode, - message: record.message + message: record.message, + permissionMode: currentPermissionMode ?? 'unknown' }); return {