From 0f3668c9ed74da4a1224565719df27ef9735e78f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Nov 2025 19:40:46 +0100 Subject: [PATCH] test(agent): add permission request handling tests for Codex agent --- .../agent/providers/codex-agent.test.ts | 102 ++++++++- .../src/server/agent/providers/codex-agent.ts | 194 +++++++++++++++++- 2 files changed, 291 insertions(+), 5 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-agent.test.ts b/packages/server/src/server/agent/providers/codex-agent.test.ts index 62fa46e59..32a3a035b 100644 --- a/packages/server/src/server/agent/providers/codex-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-agent.test.ts @@ -1,10 +1,10 @@ import { describe, expect, test } from "vitest"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, writeFileSync, chmodSync } from "node:fs"; import os from "node:os"; import path from "node:path"; import { CodexAgentClient } from "./codex-agent.js"; -import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js"; +import type { AgentSessionConfig, AgentStreamEvent, AgentPermissionRequest } from "../agent-sdk-types.js"; function tmpCwd(): string { return mkdtempSync(path.join(os.tmpdir(), "codex-agent-e2e-")); @@ -31,6 +31,38 @@ function log(message: string): void { console.info(`[CodexAgentTest] ${message}`); } +function createStubCodexBinary(events: Record[]): { path: string; cleanup: () => void } { + const dir = mkdtempSync(path.join(os.tmpdir(), "codex-stub-")); + const filePath = path.join(dir, "codex-stub.js"); + const script = `#!/usr/bin/env node +const events = ${JSON.stringify(events)}; +const emit = () => { + for (const event of events) { + process.stdout.write(JSON.stringify(event) + "\\n"); + } +}; + +if (process.stdin.isTTY) { + emit(); + process.exit(0); +} else { + process.stdin.resume(); + process.stdin.on("data", () => {}); + process.stdin.on("end", () => { + emit(); + process.exit(0); + }); +}`; + writeFileSync(filePath, script, "utf8"); + chmodSync(filePath, 0o755); + return { + path: filePath, + cleanup: () => { + rmSync(dir, { recursive: true, force: true }); + }, + }; +} + describe("CodexAgentClient (SDK integration)", () => { test( "responds with text", @@ -226,4 +258,70 @@ describe("CodexAgentClient (SDK integration)", () => { }, 180_000 ); + + test( + "emits permission requests and resolves them when approvals are handled", + async () => { + const cwd = tmpCwd(); + const restoreSessionDir = useTempCodexSessionDir(); + const events = [ + { type: "thread.started", thread_id: "019a83a2-permission" }, + { type: "turn.started" }, + { + type: "exec_approval_request", + call_id: "call-permission-1", + command: ["bash", "-lc", "rm -rf /tmp/codex"], + cwd, + reason: "Requires elevated sandbox permissions", + risk: { description: "Deletes files", risk_level: "high" }, + }, + { type: "turn.failed", error: { message: "Approval required" } }, + ]; + const stub = createStubCodexBinary(events); + const client = new CodexAgentClient({ codexPathOverride: stub.path }); + const config: AgentSessionConfig = { provider: "codex", cwd }; + let session: Awaited> | null = null; + try { + session = await client.createSession(config); + log("Processing synthetic permission request stream"); + + let captured: AgentPermissionRequest | null = null; + const streamed = session.stream("Trigger permission request"); + for await (const event of streamed) { + if (event.type === "permission_requested") { + captured = event.request; + } + if (event.type === "turn_failed") { + break; + } + } + + expect(captured).not.toBeNull(); + expect(session.getPendingPermissions()).toHaveLength(1); + + await session.respondToPermission(captured!.id, { behavior: "allow" }); + + const replay: AgentStreamEvent[] = []; + for await (const event of session.streamHistory()) { + replay.push(event); + } + + expect( + replay.some( + (event) => + event.type === "permission_resolved" && + event.requestId === captured!.id && + event.resolution.behavior === "allow" + ) + ).toBe(true); + expect(session.getPendingPermissions()).toHaveLength(0); + } finally { + await session?.close(); + stub.cleanup(); + rmSync(cwd, { recursive: true, force: true }); + restoreSessionDir(); + } + }, + 30_000 + ); }); diff --git a/packages/server/src/server/agent/providers/codex-agent.ts b/packages/server/src/server/agent/providers/codex-agent.ts index 070ad9c65..bca199379 100644 --- a/packages/server/src/server/agent/providers/codex-agent.ts +++ b/packages/server/src/server/agent/providers/codex-agent.ts @@ -198,6 +198,7 @@ class CodexAgentSession implements AgentSession { private readonly codexSessionDir: string | null; private rolloutPath: string | null; private historyEvents: AgentStreamEvent[] = []; + private pendingPermissions = new Map(); constructor(codex: Codex, config: CodexAgentConfig, handle?: AgentPersistenceHandle) { this.codex = codex; @@ -307,11 +308,35 @@ class CodexAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return []; + return Array.from(this.pendingPermissions.values()); } - async respondToPermission(_requestId: string, _response: AgentPermissionResponse): Promise { - throw new Error("Codex permission responses are not implemented yet"); + async respondToPermission(requestId: string, response: AgentPermissionResponse): Promise { + const request = this.pendingPermissions.get(requestId); + if (!request) { + throw new Error(`No pending Codex permission request with id '${requestId}'`); + } + + this.pendingPermissions.delete(requestId); + + const status = response.behavior === "allow" ? "granted" : "denied"; + this.enqueueHistoryEvent({ + type: "timeline", + provider: "codex", + item: { + type: "command", + command: `permission:${request.name}`, + status, + raw: { request, response }, + }, + }); + + this.enqueueHistoryEvent({ + type: "permission_resolved", + provider: "codex", + requestId, + resolution: response, + }); } describePersistence(): AgentPersistenceHandle | null { @@ -336,6 +361,7 @@ class CodexAgentSession implements AgentSession { async close(): Promise { this.thread = null; + this.pendingPermissions.clear(); } private drainHistoryEvents(): AgentStreamEvent[] { @@ -347,6 +373,10 @@ class CodexAgentSession implements AgentSession { return events; } + private enqueueHistoryEvent(event: AgentStreamEvent): void { + this.historyEvents.push(event); + } + private buildThreadOptions(modeId: string): ThreadOptions { const options: ThreadOptions = { workingDirectory: this.config.cwd, @@ -463,6 +493,14 @@ class CodexAgentSession implements AgentSession { private *translateEvent(event: ThreadEvent): Generator { yield { type: "provider_event", provider: "codex", raw: event }; + const permissionEvents = this.handlePermissionEvent(event); + if (permissionEvents) { + for (const permissionEvent of permissionEvents) { + yield permissionEvent; + } + return; + } + switch (event.type) { case "thread.started": this.threadId = event.thread_id; @@ -511,6 +549,148 @@ class CodexAgentSession implements AgentSession { } } + private handlePermissionEvent(event: ThreadEvent): AgentStreamEvent[] | null { + const eventType = typeof (event as { type?: string }).type === "string" ? (event as { type?: string }).type : null; + if (!eventType) { + return null; + } + + if (eventType === "exec_approval_request") { + const request = this.buildExecPermissionRequest(event as any); + return this.enqueuePermissionRequest(request); + } + + if (eventType === "apply_patch_approval_request") { + const request = this.buildPatchPermissionRequest(event as any); + return this.enqueuePermissionRequest(request); + } + + return null; + } + + private enqueuePermissionRequest(request: AgentPermissionRequest | null): AgentStreamEvent[] | null { + if (!request) { + return null; + } + + this.pendingPermissions.set(request.id, request); + + const events: AgentStreamEvent[] = [ + { + type: "timeline", + provider: "codex", + item: { + type: "command", + command: `permission:${request.name}`, + status: "requested", + raw: request.raw, + }, + }, + { type: "permission_requested", provider: "codex", request }, + ]; + + return events; + } + + private buildExecPermissionRequest(raw: any): AgentPermissionRequest | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const commandParts: string[] = Array.isArray(raw.command) + ? raw.command.filter((entry: unknown) => typeof entry === "string") + : typeof raw.command === "string" + ? [raw.command] + : []; + const commandText = commandParts.join(" ").trim(); + const cwd = typeof raw.cwd === "string" && raw.cwd.length ? raw.cwd : undefined; + const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID(); + const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined; + const risk = raw.risk && typeof raw.risk === "object" ? raw.risk : undefined; + const parsedCommand = Array.isArray(raw.parsed_cmd) ? raw.parsed_cmd : undefined; + + const metadata: Record = { + callId: raw.call_id, + command: commandText || undefined, + cwd, + reason, + }; + if (risk) { + metadata.risk = risk; + } + if (parsedCommand) { + metadata.parsedCommand = parsedCommand; + } + + const description = reason ?? (risk?.description as string | undefined); + + const request: AgentPermissionRequest = { + id: `permission-${requestId}`, + provider: "codex", + name: "exec_command", + kind: "tool", + title: commandText ? `Run command: ${commandText}` : "Run shell command", + description: description ?? undefined, + input: { + command: commandParts, + cwd, + reason, + risk, + parsedCommand, + }, + metadata: sanitizeMetadata(metadata), + raw, + }; + + return request; + } + + private buildPatchPermissionRequest(raw: any): AgentPermissionRequest | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const requestId = typeof raw.call_id === "string" && raw.call_id.length ? raw.call_id : randomUUID(); + const changes = raw.changes && typeof raw.changes === "object" ? raw.changes : undefined; + const filePaths = changes ? Object.keys(changes) : []; + const grantRoot = typeof raw.grant_root === "string" && raw.grant_root.length ? raw.grant_root : undefined; + const reason = typeof raw.reason === "string" && raw.reason.length ? raw.reason : undefined; + + const title = + filePaths.length > 0 + ? `Apply patch to ${filePaths.length} file${filePaths.length === 1 ? "" : "s"}` + : "Apply patch"; + + const metadata: Record = { + callId: raw.call_id, + files: filePaths.length ? filePaths : undefined, + grantRoot, + reason, + }; + + const input: Record = { + changes, + files: filePaths, + grantRoot, + reason, + }; + + const request: AgentPermissionRequest = { + id: `permission-${requestId}`, + provider: "codex", + name: "apply_patch", + kind: "tool", + title, + description: reason ?? undefined, + input, + suggestions: grantRoot ? [{ grantRoot }] : undefined, + metadata: sanitizeMetadata(metadata), + raw, + }; + + return request; + } + private threadItemToTimeline(item: ThreadItem): AgentTimelineItem | null { switch (item.type) { case "agent_message": @@ -897,3 +1077,11 @@ function readMetadataString(handle: AgentPersistenceHandle | undefined, key: str const value = (handle.metadata as Record)[key]; return typeof value === "string" && value.length > 0 ? value : undefined; } + +function sanitizeMetadata(metadata: Record): Record | undefined { + const entries = Object.entries(metadata).filter(([_, value]) => value !== undefined && value !== null); + if (!entries.length) { + return undefined; + } + return Object.fromEntries(entries); +}