mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user