Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 167 additions & 23 deletions packages/happy-cli/src/codex/codexMcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> };
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<ElicitParams, 'message' | 'requestedSchema'>): 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'
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<void> {
if (this.connected) return;

Expand Down Expand Up @@ -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<string, unknown>)
: {};
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<string, unknown> = {};

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'
};
}
Expand Down
14 changes: 14 additions & 0 deletions packages/happy-cli/src/codex/runCodex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`);

Expand Down Expand Up @@ -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',
Expand Down