diff --git a/packages/agent/src/adapters/codex/codex-agent.test.ts b/packages/agent/src/adapters/codex/codex-agent.test.ts new file mode 100644 index 000000000..8c3b733a8 --- /dev/null +++ b/packages/agent/src/adapters/codex/codex-agent.test.ts @@ -0,0 +1,117 @@ +import { Readable, Writable } from "node:stream"; +import type { + AgentSideConnection, + LoadSessionResponse, + NewSessionResponse, +} from "@agentclientprotocol/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockCodexConnection = { + initialize: vi.fn(), + newSession: vi.fn(), + loadSession: vi.fn(), + setSessionMode: vi.fn(), + listSessions: vi.fn(), + prompt: vi.fn(), + setSessionConfigOption: vi.fn(), +}; + +const mockKill = vi.fn(); + +vi.mock("@agentclientprotocol/sdk", async () => { + const actual = await vi.importActual("@agentclientprotocol/sdk"); + + return { + ...actual, + ClientSideConnection: vi.fn(() => mockCodexConnection), + ndJsonStream: vi.fn(() => ({}) as object), + }; +}); + +vi.mock("./spawn", () => ({ + spawnCodexProcess: vi.fn(() => ({ + process: { pid: 1234 }, + stdin: new Writable({ + write(_chunk, _encoding, callback) { + callback(); + }, + }), + stdout: new Readable({ + read() {}, + }), + kill: mockKill, + })), +})); + +vi.mock("./settings", () => ({ + CodexSettingsManager: vi.fn().mockImplementation((cwd: string) => ({ + initialize: vi.fn(), + dispose: vi.fn(), + getCwd: () => cwd, + setCwd: vi.fn(), + getSettings: () => ({}), + })), +})); + +import { CodexAcpAgent } from "./codex-agent"; + +describe("CodexAcpAgent", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createAgent(): CodexAcpAgent { + const client = { + extNotification: vi.fn(), + } as unknown as AgentSideConnection; + + return new CodexAcpAgent(client, { + codexProcessOptions: { + cwd: process.cwd(), + }, + }); + } + + it("applies the requested initial mode for a new session", async () => { + const agent = createAgent(); + mockCodexConnection.newSession.mockResolvedValue({ + sessionId: "session-1", + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + + await agent.newSession({ + cwd: process.cwd(), + _meta: { permissionMode: "read-only" }, + } as never); + + expect(mockCodexConnection.setSessionMode).toHaveBeenCalledWith({ + sessionId: "session-1", + modeId: "read-only", + }); + expect( + (agent as unknown as { sessionState: { permissionMode: string } }) + .sessionState.permissionMode, + ).toBe("read-only"); + }); + + it("preserves the live session mode when loading an existing session", async () => { + const agent = createAgent(); + mockCodexConnection.loadSession.mockResolvedValue({ + modes: { currentModeId: "read-only", availableModes: [] }, + configOptions: [], + } satisfies Partial); + + await agent.loadSession({ + sessionId: "session-1", + cwd: process.cwd(), + _meta: { permissionMode: "auto" }, + } as never); + + expect(mockCodexConnection.setSessionMode).not.toHaveBeenCalled(); + expect( + (agent as unknown as { sessionState: { permissionMode: string } }) + .sessionState.permissionMode, + ).toBe("read-only"); + }); +}); diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 77b618a64..4fbd70811 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -36,8 +36,11 @@ import { import packageJson from "../../../package.json" with { type: "json" }; import { POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; import { - CODE_EXECUTION_MODES, type CodeExecutionMode, + type CodexNativeMode, + isCodeExecutionMode, + isCodexNativeMode, + type PermissionMode, } from "../../execution-mode"; import type { ProcessSpawnedCallback } from "../../types"; import { Logger } from "../../utils/logger"; @@ -83,20 +86,41 @@ type CodexSession = BaseSession & { settingsManager: CodexSettingsManager; }; -function toCodeExecutionMode(mode?: string): CodeExecutionMode { - if (mode && (CODE_EXECUTION_MODES as readonly string[]).includes(mode)) { - return mode as CodeExecutionMode; +function toCodexPermissionMode(mode?: string): PermissionMode { + if (mode && (isCodexNativeMode(mode) || isCodeExecutionMode(mode))) { + return mode; } - return "default"; + return "auto"; } -const CODEX_NATIVE_MODE: Record = { - default: "default", - acceptEdits: "default", - plan: "plan", - bypassPermissions: "default", +const CODEX_NATIVE_MODE: Record = { + default: "auto", + acceptEdits: "auto", + plan: "read-only", + bypassPermissions: "full-access", }; +function toCodexNativeMode(mode?: string): CodexNativeMode { + if (mode && isCodexNativeMode(mode)) { + return mode; + } + if (mode && isCodeExecutionMode(mode)) { + return CODEX_NATIVE_MODE[mode]; + } + return "auto"; +} + +function getCurrentPermissionMode( + currentModeId?: string, + fallbackMode?: string, +): PermissionMode { + if (currentModeId && isCodexNativeMode(currentModeId)) { + return currentModeId; + } + + return toCodexPermissionMode(fallbackMode); +} + export class CodexAcpAgent extends BaseAcpAgent { readonly adapterName = "codex"; declare session: CodexSession; @@ -179,6 +203,7 @@ export class CodexAcpAgent extends BaseAcpAgent { async newSession(params: NewSessionRequest): Promise { const meta = params._meta as NewSessionMeta | undefined; + const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode); const response = await this.codexConnection.newSession(params); @@ -186,13 +211,19 @@ export class CodexAcpAgent extends BaseAcpAgent { this.sessionState = createSessionState(response.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: meta?.taskId ?? meta?.persistence?.taskId, - modeId: response.modes?.currentModeId ?? "default", + modeId: response.modes?.currentModeId ?? "auto", modelId: response.models?.currentModelId, - permissionMode: toCodeExecutionMode(meta?.permissionMode), + permissionMode: requestedPermissionMode, }); this.sessionId = response.sessionId; this.sessionState.configOptions = response.configOptions ?? []; + await this.applyInitialPermissionMode( + response.sessionId, + meta?.permissionMode, + response.modes?.currentModeId, + ); + // Emit _posthog/sdk_session so the app can track the session if (meta?.taskRunId) { await this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { @@ -213,9 +244,14 @@ export class CodexAcpAgent extends BaseAcpAgent { async loadSession(params: LoadSessionRequest): Promise { const response = await this.codexConnection.loadSession(params); const meta = params._meta as NewSessionMeta | undefined; + const currentPermissionMode = getCurrentPermissionMode( + response.modes?.currentModeId, + meta?.permissionMode, + ); this.sessionState = createSessionState(params.sessionId, params.cwd, { - permissionMode: toCodeExecutionMode(meta?.permissionMode), + modeId: response.modes?.currentModeId ?? "auto", + permissionMode: currentPermissionMode, }); this.sessionId = params.sessionId; this.sessionState.configOptions = response.configOptions ?? []; @@ -234,10 +270,15 @@ export class CodexAcpAgent extends BaseAcpAgent { }); const meta = params._meta as NewSessionMeta | undefined; + const currentPermissionMode = getCurrentPermissionMode( + loadResponse.modes?.currentModeId, + meta?.permissionMode, + ); this.sessionState = createSessionState(params.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: meta?.taskId ?? meta?.persistence?.taskId, - permissionMode: toCodeExecutionMode(meta?.permissionMode), + modeId: loadResponse.modes?.currentModeId ?? "auto", + permissionMode: currentPermissionMode, }); this.sessionId = params.sessionId; this.sessionState.configOptions = loadResponse.configOptions ?? []; @@ -268,17 +309,49 @@ export class CodexAcpAgent extends BaseAcpAgent { }); const meta = params._meta as NewSessionMeta | undefined; + const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode); this.sessionState = createSessionState(newResponse.sessionId, params.cwd, { taskRunId: meta?.taskRunId, taskId: meta?.taskId ?? meta?.persistence?.taskId, - permissionMode: toCodeExecutionMode(meta?.permissionMode), + modeId: newResponse.modes?.currentModeId ?? "auto", + permissionMode: requestedPermissionMode, }); this.sessionId = newResponse.sessionId; this.sessionState.configOptions = newResponse.configOptions ?? []; + await this.applyInitialPermissionMode( + newResponse.sessionId, + meta?.permissionMode, + newResponse.modes?.currentModeId, + ); + return newResponse; } + private async applyInitialPermissionMode( + sessionId: string, + permissionMode?: string, + currentModeId?: string, + ): Promise { + if (!permissionMode) { + return; + } + + const nativeMode = toCodexNativeMode(permissionMode); + if (nativeMode === currentModeId) { + this.sessionState.modeId = nativeMode; + this.sessionState.permissionMode = toCodexPermissionMode(permissionMode); + return; + } + + await this.codexConnection.setSessionMode({ + sessionId, + modeId: nativeMode, + }); + this.sessionState.modeId = nativeMode; + this.sessionState.permissionMode = toCodexPermissionMode(permissionMode); + } + async listSessions( params: ListSessionsRequest, ): Promise { @@ -347,8 +420,8 @@ export class CodexAcpAgent extends BaseAcpAgent { async setSessionMode( params: SetSessionModeRequest, ): Promise { - const requestedMode = toCodeExecutionMode(params.modeId); - const nativeMode = CODEX_NATIVE_MODE[requestedMode]; + const requestedMode = toCodexPermissionMode(params.modeId); + const nativeMode = toCodexNativeMode(params.modeId); const response = await this.codexConnection.setSessionMode({ ...params, diff --git a/packages/agent/src/adapters/codex/codex-client.ts b/packages/agent/src/adapters/codex/codex-client.ts index fecf7ab70..f56ab4493 100644 --- a/packages/agent/src/adapters/codex/codex-client.ts +++ b/packages/agent/src/adapters/codex/codex-client.ts @@ -29,7 +29,7 @@ import type { WriteTextFileRequest, WriteTextFileResponse, } from "@agentclientprotocol/sdk"; -import type { CodeExecutionMode } from "../../execution-mode"; +import type { PermissionMode } from "../../execution-mode"; import type { Logger } from "../../utils/logger"; import type { CodexSessionState } from "./session-state"; @@ -38,7 +38,7 @@ export interface CodexClientCallbacks { onUsageUpdate?: (update: Record) => void; } -const AUTO_APPROVED_KINDS: Record> = { +const AUTO_APPROVED_KINDS: Record> = { default: new Set(["read", "search", "fetch", "think"]), acceptEdits: new Set(["read", "edit", "search", "fetch", "think"]), plan: new Set(["read", "search", "fetch", "think"]), @@ -54,13 +54,27 @@ const AUTO_APPROVED_KINDS: Record> = { "switch_mode", "other", ]), + auto: new Set(["read", "search", "fetch", "think"]), + "read-only": new Set(["read", "search", "fetch", "think"]), + "full-access": new Set([ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "switch_mode", + "other", + ]), }; function shouldAutoApprove( - mode: CodeExecutionMode, + mode: PermissionMode, kind: ToolKind | null | undefined, ): boolean { - if (mode === "bypassPermissions") return true; + if (mode === "bypassPermissions" || mode === "full-access") return true; if (!kind) return false; return AUTO_APPROVED_KINDS[mode]?.has(kind) ?? false; } diff --git a/packages/agent/src/adapters/codex/session-state.ts b/packages/agent/src/adapters/codex/session-state.ts index dcc7203ac..1c68ee8d4 100644 --- a/packages/agent/src/adapters/codex/session-state.ts +++ b/packages/agent/src/adapters/codex/session-state.ts @@ -4,7 +4,7 @@ */ import type { SessionConfigOption } from "@agentclientprotocol/sdk"; -import type { CodeExecutionMode } from "../../execution-mode"; +import type { PermissionMode } from "../../execution-mode"; export interface CodexUsage { inputTokens: number; @@ -22,7 +22,7 @@ export interface CodexSessionState { accumulatedUsage: CodexUsage; contextSize?: number; contextUsed?: number; - permissionMode: CodeExecutionMode; + permissionMode: PermissionMode; taskRunId?: string; taskId?: string; } @@ -35,13 +35,13 @@ export function createSessionState( taskId?: string; modeId?: string; modelId?: string; - permissionMode?: CodeExecutionMode; + permissionMode?: PermissionMode; }, ): CodexSessionState { return { sessionId, cwd, - modeId: opts?.modeId ?? "default", + modeId: opts?.modeId ?? "auto", modelId: opts?.modelId, configOptions: [], accumulatedUsage: { @@ -50,7 +50,7 @@ export function createSessionState( cachedReadTokens: 0, cachedWriteTokens: 0, }, - permissionMode: opts?.permissionMode ?? "default", + permissionMode: opts?.permissionMode ?? "auto", taskRunId: opts?.taskRunId, taskId: opts?.taskId, }; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 69f9dd620..e873336bb 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -18,7 +18,7 @@ import { type InProcessAcpConnection, } from "../adapters/acp-connection"; import { selectRecentTurns } from "../adapters/claude/session/jsonl-hydration"; -import type { CodeExecutionMode } from "../execution-mode"; +import type { PermissionMode } from "../execution-mode"; import { DEFAULT_CODEX_MODEL } from "../gateway-models"; import { PostHogAPIClient } from "../posthog-api"; import { @@ -164,7 +164,7 @@ interface ActiveSession { deviceInfo: DeviceInfo; logWriter: SessionLogWriter; /** Current permission mode, tracked for relay decisions */ - permissionMode: CodeExecutionMode; + permissionMode: PermissionMode; /** Whether a desktop client has ever connected via SSE during this session */ hasDesktopConnected: boolean; } @@ -265,8 +265,16 @@ export class AgentServer { return payload.mode ?? this.config.mode; } - private getSessionPermissionMode(): CodeExecutionMode { - return this.session?.permissionMode ?? "default"; + private getSessionPermissionMode(): PermissionMode { + if (this.session?.permissionMode) { + return this.session.permissionMode; + } + + return this.getRuntimeAdapter() === "codex" ? "auto" : "default"; + } + + private shouldRelayPermissionToClient(mode: PermissionMode): boolean { + return mode === "default" || mode === "auto"; } private createApp(): Hono { @@ -839,12 +847,15 @@ export class AgentServer { }); const runState = preTaskRun?.state as Record | undefined; - // Cloud runs default to bypassPermissions (auto-approve everything). - // Only PostHog Code sets initial_permission_mode explicitly (e.g., "plan"). - const initialPermissionMode: CodeExecutionMode = + // Preserve native Codex modes for cloud runs so they behave the same as + // local sessions. Claude keeps the historical auto-approved default when + // PostHog Code has not explicitly selected a mode. + const initialPermissionMode: PermissionMode = typeof runState?.initial_permission_mode === "string" - ? (runState.initial_permission_mode as CodeExecutionMode) - : "bypassPermissions"; + ? (runState.initial_permission_mode as PermissionMode) + : runtimeAdapter === "codex" + ? "auto" + : "bypassPermissions"; const sessionResponse = await clientConnection.newSession({ cwd: this.config.repositoryPath ?? "/tmp/workspace", mcpServers: this.config.mcpServers ?? [], @@ -1588,7 +1599,9 @@ ${attributionInstructions} const isQuestion = codeToolKind === "question"; const sessionPermissionMode = this.getSessionPermissionMode(); const needsRelay = - isQuestion || isPlanApproval || sessionPermissionMode === "default"; + isQuestion || + isPlanApproval || + this.shouldRelayPermissionToClient(sessionPermissionMode); if (needsRelay && this.session?.hasDesktopConnected) { this.logger.info("Relaying permission to connected client", { @@ -1634,7 +1647,7 @@ ${attributionInstructions} this.session ) { this.session.permissionMode = params.update - .currentModeId as CodeExecutionMode; + .currentModeId as PermissionMode; this.logger.info("Permission mode updated", { mode: params.update.currentModeId, });