diff --git a/packages/happy-cli/src/codex/codexMcpClient.ts b/packages/happy-cli/src/codex/codexMcpClient.ts index ed101394c..b1d73b832 100644 --- a/packages/happy-cli/src/codex/codexMcpClient.ts +++ b/packages/happy-cli/src/codex/codexMcpClient.ts @@ -7,12 +7,50 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { logger } from '@/ui/logger'; import type { CodexSessionConfig, CodexToolResponse } from './types'; import { z } from 'zod'; -import { ElicitRequestSchema } from '@modelcontextprotocol/sdk/types.js'; import { CodexPermissionHandler } from './utils/permissionHandler'; import { execSync } from 'child_process'; +import { randomUUID } from 'node:crypto'; const DEFAULT_TIMEOUT = 14 * 24 * 60 * 60 * 1000; // 14 days, which is the half of the maximum possible timeout (~28 days for int32 value in NodeJS) +// Use a loose schema to preserve non-standard fields (e.g., codex_* params) +const ElicitRequestSchemaLoose = z.object({ + method: z.literal('elicitation/create'), + params: z.unknown().optional() +}).passthrough(); + +type ElicitParams = { + message?: string; + requestedSchema?: unknown; + codex_elicitation?: string; + codex_mcp_tool_call_id?: string; + codex_event_id?: string; + codex_call_id?: string; + codex_command?: string[]; + codex_cwd?: string; +}; + +function hasEmptySchema(schema: unknown): boolean { + if (!schema || typeof schema !== 'object') return false; + const maybe = schema as { type?: string; properties?: Record }; + if (maybe.type && maybe.type !== 'object') return false; + if (!('properties' in maybe)) return true; + if (!maybe.properties) return true; + return Object.keys(maybe.properties).length === 0; +} + +function isLikelyExecApprovalElicitation(params: Pick): boolean { + if (!params.message) return false; + const msg = params.message; + const lower = msg.toLowerCase(); + if (lower.includes('allow codex to run')) return true; + if (lower.includes('codex to run')) return true; + const hasBackticks = msg.includes('`'); + const looksLikeCommand = /\/bin\/zsh|\bpython\b|\bpython3\b|\bnode\b|\bbash\b|\bsh\b/.test(msg); + if (hasBackticks && looksLikeCommand) return true; + return hasEmptySchema(params.requestedSchema); +} + /** * Get the correct MCP subcommand based on installed codex version * Versions >= 0.43.0-alpha.5 use 'mcp-server', older versions use 'mcp' @@ -55,6 +93,13 @@ export class CodexMcpClient { private conversationId: string | null = null; private handler: ((event: any) => void) | null = null; private permissionHandler: CodexPermissionHandler | null = null; + private onPermissionDecision: ((decision: 'approved' | 'approved_for_session' | 'denied' | 'abort') => void) | null = null; + private pendingExecApprovals: Array<{ + callId: string; + command?: string[]; + cwd?: string; + reason?: string | null; + }> = []; constructor() { this.client = new Client( @@ -85,6 +130,35 @@ export class CodexMcpClient { this.permissionHandler = handler; } + /** + * Optional hook to react to permission decisions (e.g., abort current turn). + */ + setPermissionDecisionHandler(handler: ((decision: 'approved' | 'approved_for_session' | 'denied' | 'abort') => void) | null): void { + this.onPermissionDecision = handler; + } + + /** + * Track exec approval requests so we can correlate MCP elicitation prompts + * with the actual tool call id and command details. + */ + trackExecApprovalRequest(request: { + callId: string; + command?: string[]; + cwd?: string; + reason?: string | null; + }): void { + if (!request.callId) return; + if (this.pendingExecApprovals.some((entry) => entry.callId === request.callId)) { + return; + } + this.pendingExecApprovals.push(request); + // Prevent unbounded growth if something goes wrong + if (this.pendingExecApprovals.length > 50) { + this.pendingExecApprovals.shift(); + } + logger.debug('[CodexMCP] Tracked exec approval request', { callId: request.callId }); + } + async connect(): Promise { if (this.connected) return; @@ -126,49 +200,119 @@ export class CodexMcpClient { private registerPermissionHandlers(): void { // Register handler for exec command approval requests this.client.setRequestHandler( - ElicitRequestSchema, + ElicitRequestSchemaLoose, async (request) => { console.log('[CodexMCP] Received elicitation request:', request.params); - // Load params - const params = request.params as unknown as { - message: string, - codex_elicitation: string, - codex_mcp_tool_call_id: string, - codex_event_id: string, - codex_call_id: string, - codex_command: string[], - codex_cwd: string - } + const rawParams = request.params && typeof request.params === 'object' + ? (request.params as Record) + : {}; + const params = rawParams as ElicitParams; const toolName = 'CodexBash'; + const codexKeys = [ + 'codex_elicitation', + 'codex_mcp_tool_call_id', + 'codex_event_id', + 'codex_call_id', + 'codex_command', + 'codex_cwd' + ]; + const isCodexExecApproval = codexKeys.some((key) => key in rawParams); + + let toolCallId: string | undefined; + let toolInput: Record = {}; + + if (isCodexExecApproval) { + toolCallId = params.codex_call_id || params.codex_mcp_tool_call_id || params.codex_event_id; + toolInput = { + command: Array.isArray(params.codex_command) ? params.codex_command : undefined, + cwd: typeof params.codex_cwd === 'string' ? params.codex_cwd : undefined + }; + } else { + const shouldConsumePending = + this.pendingExecApprovals.length > 0 && + isLikelyExecApprovalElicitation({ + message: params.message, + requestedSchema: params.requestedSchema + }); + const pending = shouldConsumePending ? this.pendingExecApprovals.shift() : undefined; + if (pending) { + toolCallId = pending.callId; + toolInput = { + command: pending.command, + cwd: pending.cwd, + reason: pending.reason ?? undefined + }; + } else { + toolCallId = randomUUID(); + toolInput = { + message: params.message, + requestedSchema: params.requestedSchema + }; + if (this.pendingExecApprovals.length > 0) { + logger.debug('[CodexMCP] Pending exec approvals left intact for non-exec elicitation', { + pendingCount: this.pendingExecApprovals.length, + message: params.message + }); + } + logger.debug('[CodexMCP] No pending exec approval to match elicitation; using fallback id'); + } + } + + if (!toolCallId) { + toolCallId = randomUUID(); + } // If no permission handler set, deny by default if (!this.permissionHandler) { logger.debug('[CodexMCP] No permission handler set, denying by default'); - return { - decision: 'denied' as const, - }; + if (isCodexExecApproval) { + return { action: 'decline' as const, decision: 'denied' as const }; + } + return { action: 'decline' as const }; } try { // Request permission through the handler const result = await this.permissionHandler.handleToolCall( - params.codex_call_id, + toolCallId, toolName, - { - command: params.codex_command, - cwd: params.codex_cwd - } + toolInput ); logger.debug('[CodexMCP] Permission result:', result); - return { - decision: result.decision + if (this.onPermissionDecision) { + this.onPermissionDecision(result.decision); } + const action = + result.decision === 'approved' || result.decision === 'approved_for_session' + ? 'accept' + : result.decision === 'abort' + ? 'cancel' + : 'decline'; + + if (isCodexExecApproval) { + // Codex MCP currently expects a legacy `decision` field, but MCP validation + // now enforces the standard `action`. Return both. + return action === 'accept' + ? { action, decision: result.decision, content: {} } + : { action, decision: result.decision }; + } + + return action === 'accept' + ? { action, content: {} } + : { action }; } catch (error) { logger.debug('[CodexMCP] Error handling permission request:', error); + if (isCodexExecApproval) { + return { + action: 'decline' as const, + decision: 'denied' as const, + reason: error instanceof Error ? error.message : 'Permission request failed' + }; + } return { - decision: 'denied' as const, + action: 'decline' as const, reason: error instanceof Error ? error.message : 'Permission request failed' }; } diff --git a/packages/happy-cli/src/codex/runCodex.ts b/packages/happy-cli/src/codex/runCodex.ts index fdaf9b29d..28f8331b2 100644 --- a/packages/happy-cli/src/codex/runCodex.ts +++ b/packages/happy-cli/src/codex/runCodex.ts @@ -405,6 +405,12 @@ export async function runCodex(opts: { session.sendCodexMessage(message); }); client.setPermissionHandler(permissionHandler); + client.setPermissionDecisionHandler((decision) => { + if (decision === 'abort') { + logger.debug('[Codex] Permission decision=abort; aborting current turn'); + void handleAbort(); + } + }); client.setHandler((msg) => { logger.debug(`[Codex] MCP message: ${JSON.stringify(msg)}`); @@ -471,6 +477,14 @@ export async function runCodex(opts: { } if (msg.type === 'exec_command_begin' || msg.type === 'exec_approval_request') { let { call_id, type, ...inputs } = msg; + if (msg.type === 'exec_approval_request') { + client.trackExecApprovalRequest({ + callId: call_id, + command: Array.isArray((inputs as any).command) ? (inputs as any).command : undefined, + cwd: typeof (inputs as any).cwd === 'string' ? (inputs as any).cwd : undefined, + reason: (inputs as any).reason ?? null + }); + } session.sendCodexMessage({ type: 'tool-call', name: 'CodexBash',