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
This commit is contained in:
CoColate
2026-05-21 17:09:10 +08:00
committed by GitHub
parent 8e3bba9303
commit 611c4eec49
4 changed files with 162 additions and 7 deletions
+55 -1
View File
@@ -836,6 +836,7 @@ function createSessionStub(messages = ['hello from launcher test'], mode = creat
const foundSessionIds: string[] = [];
const resetThreadCalls: string[] = [];
const collaborationModes: Array<EnhancedMode['collaborationMode'] | undefined> = [];
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',
+5 -2
View File
@@ -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);
@@ -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 = {
@@ -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<string, string[]> | Record<string, { answers: string[] }> }
| { 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 {