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
61 changes: 59 additions & 2 deletions packages/happy-cli/src/codex/__tests__/codexMcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,19 @@ const {
mockSandboxCleanup,
mockClientConnect,
mockClientClose,
mockClientSetRequestHandler,
mockStdioCtor,
mockClients,
} = vi.hoisted(() => ({
mockExecSync: vi.fn(),
mockInitializeSandbox: vi.fn(),
mockWrapForMcpTransport: vi.fn(),
mockSandboxCleanup: vi.fn(),
mockClientConnect: vi.fn(),
mockClientClose: vi.fn(),
mockClientSetRequestHandler: vi.fn(),
mockStdioCtor: vi.fn(),
mockClients: [] as any[],
}));

vi.mock('child_process', () => ({
Expand All @@ -40,11 +44,14 @@ vi.mock('@/ui/logger', () => ({
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
Client: class MockClient {
setNotificationHandler = vi.fn();
setRequestHandler = vi.fn();
setRequestHandler = mockClientSetRequestHandler;
connect = mockClientConnect;
close = mockClientClose;
callTool = vi.fn();
constructor() {}
_requestHandlers = new Map();
constructor() {
mockClients.push(this);
}
},
}));

Expand Down Expand Up @@ -77,6 +84,7 @@ describe('CodexMcpClient sandbox integration', () => {

beforeEach(() => {
vi.clearAllMocks();
mockClients.length = 0;
process.env.RUST_LOG = originalRustLog;
mockExecSync.mockReturnValue('codex-cli 0.43.0');
mockClientConnect.mockResolvedValue(undefined);
Expand Down Expand Up @@ -152,4 +160,53 @@ describe('CodexMcpClient sandbox integration', () => {
}),
);
});

it('registers a raw elicitation handler for Codex approvals', async () => {
const client = new CodexMcpClient(sandboxConfig);

await client.connect();

const rawHandler = mockClients[0]?._requestHandlers.get('elicitation/create');
expect(typeof rawHandler).toBe('function');
expect(mockClientSetRequestHandler).not.toHaveBeenCalled();
});

it('normalizes approved_for_session to approved for Codex approvals', async () => {
const client = new CodexMcpClient(sandboxConfig);
client.setPermissionHandler({
handleToolCall: vi.fn().mockResolvedValue({ decision: 'approved_for_session' }),
} as any);

await client.connect();

const rawHandler = mockClients[0]?._requestHandlers.get('elicitation/create');
const response = await rawHandler({
params: {
codex_call_id: 'call_123',
codex_command: ['mkdir', '-p', '../test'],
codex_cwd: '/tmp/project',
},
});

expect(response).toEqual({ decision: 'approved' });
});

it('aborts approvals when Codex call id is missing', async () => {
const client = new CodexMcpClient(sandboxConfig);
client.setPermissionHandler({
handleToolCall: vi.fn(),
} as any);

await client.connect();

const rawHandler = mockClients[0]?._requestHandlers.get('elicitation/create');
const response = await rawHandler({
params: {
codex_command: ['pwd'],
codex_cwd: '/tmp/project',
},
});

expect(response).toEqual({ decision: 'abort' });
});
});
111 changes: 63 additions & 48 deletions packages/happy-cli/src/codex/codexMcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,32 @@ 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 type { SandboxConfig } from '@/persistence';
import { initializeSandbox, wrapForMcpTransport } from '@/sandbox/manager';

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)

type CodexApprovalDecision = 'approved' | 'abort';

type CodexElicitationParams = {
message?: string;
codex_elicitation?: string;
codex_mcp_tool_call_id?: string;
codex_event_id?: string;
codex_call_id?: string;
codex_command?: string[] | string;
codex_cwd?: string;
};

type CodexRawRequestClient = {
_requestHandlers?: Map<
string,
(request: { params: CodexElicitationParams }) => Promise<{ decision: CodexApprovalDecision }>
>;
};

/**
* 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 @@ -186,56 +204,53 @@ export class CodexMcpClient {
}

private registerPermissionHandlers(): void {
// Register handler for exec command approval requests
this.client.setRequestHandler(
ElicitRequestSchema,
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 toolName = 'CodexBash';

// 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,
};
}
const rawClient = this.client as unknown as CodexRawRequestClient;
const requestHandlers = rawClient._requestHandlers;

try {
// Request permission through the handler
const result = await this.permissionHandler.handleToolCall(
params.codex_call_id,
toolName,
{
command: params.codex_command,
cwd: params.codex_cwd
}
);
if (!(requestHandlers instanceof Map)) {
throw new Error('Codex MCP client does not expose raw request handlers');
}

logger.debug('[CodexMCP] Permission result:', result);
return {
decision: result.decision
}
} catch (error) {
logger.debug('[CodexMCP] Error handling permission request:', error);
return {
decision: 'denied' as const,
reason: error instanceof Error ? error.message : 'Permission request failed'
};
}
// Codex expects a non-standard top-level `{ decision }` approval payload.
requestHandlers.set('elicitation/create', async (request: { params: CodexElicitationParams }) => {
logger.debug('[CodexMCP] Received elicitation request:', request.params);

const params = request.params ?? {};
const toolName = 'CodexBash';
const toolCallId = params.codex_call_id;

if (!toolCallId) {
logger.debug('[CodexMCP] Missing codex_call_id, aborting request');
return { decision: 'abort' };
}
);

if (!this.permissionHandler) {
logger.debug('[CodexMCP] No permission handler set, aborting by default');
return { decision: 'abort' };
}

try {
const result = await this.permissionHandler.handleToolCall(
toolCallId,
toolName,
{
command: params.codex_command,
cwd: params.codex_cwd,
},
);

const decision: CodexApprovalDecision =
result.decision === 'approved' || result.decision === 'approved_for_session'
? 'approved'
: 'abort';

logger.debug('[CodexMCP] Elicitation response:', { decision });
return { decision };
} catch (error) {
logger.debug('[CodexMCP] Error handling permission request:', error);
return { decision: 'abort' };
}
});

logger.debug('[CodexMCP] Permission handlers registered');
}
Expand Down