Files
hapi/cli/src/codex/utils/appServerPermissionAdapter.ts
T
CoColateandGitHub 611c4eec49 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
2026-05-21 17:09:10 +08:00

295 lines
8.8 KiB
TypeScript

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';
type PermissionDecision = 'approved' | 'approved_for_session' | 'denied' | 'abort';
type PermissionResult = {
decision: PermissionDecision;
reason?: string;
};
type ElicitationSchemaProperty = {
type?: unknown;
default?: unknown;
enum?: unknown;
oneOf?: unknown;
items?: unknown;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function pickToolName(record: Record<string, unknown>): string {
return asString(record.toolName)
?? asString(record.tool_name)
?? asString(record.tool)
?? asString(record.name)
?? asString(record.permission)
?? 'CodexTool';
}
function mapDecision(decision: PermissionDecision): { decision: string } {
switch (decision) {
case 'approved':
return { decision: 'accept' };
case 'approved_for_session':
return { decision: 'acceptForSession' };
case 'denied':
return { decision: 'decline' };
case 'abort':
return { decision: 'cancel' };
}
}
function mapPermissionGrant(
requested: unknown,
decision: PermissionDecision
): {
permissions: unknown;
scope: 'turn' | 'session';
} {
if (decision === 'approved' || decision === 'approved_for_session') {
return {
permissions: requested,
scope: decision === 'approved_for_session' ? 'session' : 'turn'
};
}
return {
permissions: {
network: null,
fileSystem: null
},
scope: 'turn'
};
}
function firstString(values: unknown): string | undefined {
if (!Array.isArray(values)) {
return undefined;
}
return values.find((value): value is string => typeof value === 'string');
}
function firstConst(values: unknown): string | undefined {
if (!Array.isArray(values)) {
return undefined;
}
for (const value of values) {
const record = asRecord(value);
if (typeof record?.const === 'string') {
return record.const;
}
}
return undefined;
}
function defaultValueForElicitationProperty(property: ElicitationSchemaProperty): unknown {
if ('default' in property) {
return property.default;
}
switch (property.type) {
case 'string':
return firstString(property.enum)
?? firstConst(property.oneOf)
?? '';
case 'boolean':
return true;
case 'number':
case 'integer':
return 0;
case 'array': {
const items = asRecord(property.items);
const value = firstString(items?.enum)
?? firstConst(items?.anyOf);
return value ? [value] : [];
}
default:
return null;
}
}
function buildAcceptedElicitationContent(params: unknown): Record<string, unknown> {
const record = asRecord(params);
const schema = asRecord(record?.requestedSchema);
const properties = asRecord(schema?.properties);
if (!properties) {
return {};
}
const required = Array.isArray(schema?.required)
? schema.required.filter((value): value is string => typeof value === 'string')
: Object.keys(properties);
const content: Record<string, unknown> = {};
for (const key of required) {
const property = asRecord(properties[key]);
if (!property) {
continue;
}
content[key] = defaultValueForElicitationProperty(property);
}
return content;
}
function isHapiBridgeElicitation(params: unknown): boolean {
const record = asRecord(params);
return record?.serverName === 'hapi';
}
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, getPermissionMode, onUserInputRequest } = args;
client.registerRequestHandler('item/commandExecution/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const reason = asString(record.reason);
const command = record.command;
const cwd = asString(record.cwd);
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexBash',
{
message: reason,
command,
cwd
}
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/fileChange/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const reason = asString(record.reason);
const grantRoot = asString(record.grantRoot);
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexPatch',
{
message: reason,
grantRoot
}
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/permissions/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const permissions = record.permissions ?? {};
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexPermission',
{
message: asString(record.reason),
cwd: asString(record.cwd),
permissions
}
) as PermissionResult;
return mapPermissionGrant(permissions, result.decision);
});
client.registerRequestHandler('item/tool/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? asString(record.item_id) ?? randomUUID();
const toolName = pickToolName(record);
const result = await permissionHandler.handleToolCall(
toolCallId,
toolName,
record.input ?? record.arguments ?? params
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/tool/requestUserInput', async (params) => {
const record = asRecord(params) ?? {};
const requestId = asString(record.itemId) ?? randomUUID();
if (!onUserInputRequest) {
logger.debug('[CodexAppServer] No user-input handler registered; cancelling request');
return { decision: 'cancel' };
}
const result = await onUserInputRequest({
id: requestId,
input: params
});
if (result.decision !== 'accept') {
return { decision: result.decision };
}
return result;
});
client.registerRequestHandler('mcpServer/elicitation/request', async (params) => {
const record = asRecord(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,
permissionMode: currentPermissionMode ?? 'unknown'
});
return {
action: 'cancel',
content: null,
_meta: null
};
}
logger.debug('[CodexAppServer] Accepting MCP elicitation request', {
serverName: record.serverName,
mode: record.mode,
message: record.message,
permissionMode: currentPermissionMode ?? 'unknown'
});
return {
action: 'accept',
content: buildAcceptedElicitationContent(params),
_meta: null
};
});
}