diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 0c7af207..9ec93355 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -704,7 +704,7 @@ export class CodexAcpClient { threadId: request.sessionId, input: input, approvalPolicy: agentMode.approvalPolicy, - sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories), + sandboxPolicy: createTurnSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories, this.config), summary: disableSummary ? "none" : "auto", effort: effort, model: modelId.model, @@ -1062,6 +1062,35 @@ function addAdditionalDirectoriesToSandboxPolicy( }; } +function createTurnSandboxPolicy( + sandboxPolicy: SandboxPolicy, + additionalDirectories: string[], + config: JsonObject, +): SandboxPolicy { + const policy = addAdditionalDirectoriesToSandboxPolicy(sandboxPolicy, additionalDirectories); + const workspaceWriteConfig = config["sandbox_workspace_write"]; + + if (policy.type !== "workspaceWrite" || !isJsonObject(workspaceWriteConfig)) { + return policy; + } + + const configuredWritableRoots = Array.isArray(workspaceWriteConfig["writable_roots"]) + ? workspaceWriteConfig["writable_roots"].filter( + (value): value is string => typeof value === "string" && path.isAbsolute(value) + ) + : []; + const writableRoots = uniqueStrings([...policy.writableRoots, ...configuredWritableRoots]); + const networkAccess = workspaceWriteConfig["network_access"] === true + ? true + : policy.networkAccess; + + return { + ...policy, + writableRoots, + networkAccess, + }; +} + function uniqueStrings(values: string[]): string[] { return Array.from(new Set(values)); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f1fb9cec..5f6d374d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -254,6 +254,13 @@ export class CodexAcpServer { steering: { supported: true, }, + codex: { + workspaceWriteConfig: { + version: 1, + networkAccess: true, + writableRoots: true, + }, + }, }, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index d58bd594..4f42f18b 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -8,6 +8,7 @@ import { createTestFixture, createTestModel, createTestSessionState, + mockPromptTurn, type TestFixture } from "../acp-test-utils"; import type {ServerNotification} from "../../app-server"; @@ -17,6 +18,7 @@ import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; +import type {JsonObject} from "../../CodexAcpClient"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -848,6 +850,123 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + async function promptWithSandboxConfig( + codexConfig: JsonObject | undefined, + agentMode: AgentMode = AgentMode.Agent, + ): Promise { + const mockFixture = createCodexMockTestFixture(codexConfig); + const sessionState = createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + additionalDirectories: ["/workspace/extra"], + agentMode, + }); + vi.spyOn(mockFixture.getCodexAcpAgent(), "getSessionState").mockReturnValue(sessionState); + const turnStartSpy = mockPromptTurn(mockFixture, sessionState.sessionId); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId: sessionState.sessionId, + prompt: [{type: "text", text: "Hello"}], + }); + + return turnStartSpy.mock.calls[0]![0]; + } + + it('enables workspace-write network access for an explicit true config', async () => { + const turnStart = await promptWithSandboxConfig({ + sandbox_workspace_write: {network_access: true}, + }); + + expect(turnStart.approvalPolicy).toBe("on-request"); + expect(turnStart.sandboxPolicy).toEqual({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra"], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }); + }); + + it('applies explicit absolute workspace-write roots to every turn', async () => { + const turnStart = await promptWithSandboxConfig({ + sandbox_workspace_write: { + network_access: true, + writable_roots: [ + "/broker/requests", + "/workspace/extra", + "relative/path", + 7, + ], + }, + }); + + expect(turnStart.sandboxPolicy).toEqual({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra", "/broker/requests"], + networkAccess: true, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false, + }); + }); + + it('applies configured workspace-write roots without enabling network implicitly', async () => { + const turnStart = await promptWithSandboxConfig({ + sandbox_workspace_write: {writable_roots: ["/broker/requests"]}, + }); + + expect(turnStart.sandboxPolicy).toMatchObject({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra", "/broker/requests"], + networkAccess: false, + }); + }); + + it('ignores malformed or non-absolute configured workspace-write roots', async () => { + const malformed = await promptWithSandboxConfig({ + sandbox_workspace_write: {writable_roots: "/too-broad"}, + }); + const relative = await promptWithSandboxConfig({ + sandbox_workspace_write: {writable_roots: ["relative/path"]}, + }); + + expect(malformed.sandboxPolicy).toMatchObject({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra"], + }); + expect(relative.sandboxPolicy).toMatchObject({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra"], + }); + }); + + it.each([ + ["missing", undefined], + ["false", {sandbox_workspace_write: {network_access: false}}], + ["non-boolean", {sandbox_workspace_write: {network_access: "true"}}], + ] as const)('keeps workspace-write network access disabled when config is %s', async (_label, codexConfig) => { + const turnStart = await promptWithSandboxConfig(codexConfig); + + expect(turnStart.sandboxPolicy).toMatchObject({ + type: "workspaceWrite", + writableRoots: ["/workspace/extra"], + networkAccess: false, + }); + }); + + it.each([ + ["read-only", AgentMode.ReadOnly, {type: "readOnly", networkAccess: false}], + ["full-access", AgentMode.AgentFullAccess, {type: "dangerFullAccess"}], + ] as const)('does not alter %s mode for workspace-write network config', async (_label, agentMode, expectedPolicy) => { + const turnStart = await promptWithSandboxConfig({ + sandbox_workspace_write: { + network_access: true, + writable_roots: ["/broker/requests"], + }, + }, agentMode); + + expect(turnStart.sandboxPolicy).toEqual(expectedPolicy); + }); + function loadNotifications(){ //TODO collect logs form dev run and then load them from file to speedup const serverNotifications: ServerNotification[] = [ diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 9d6dc2b8..21c0da68 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -65,6 +65,13 @@ describe('CodexACPAgent - initialize', () => { steering: { supported: true, }, + codex: { + workspaceWriteConfig: { + version: 1, + networkAccess: true, + writableRoots: true, + }, + }, }, }); }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index de4ad962..6b2d842c 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -1,6 +1,6 @@ import * as acp from "@agentclientprotocol/sdk"; import type {CreateElicitationResponse, McpServerStdio, RequestPermissionResponse} from "@agentclientprotocol/sdk"; -import {CodexAcpClient} from '../CodexAcpClient'; +import {CodexAcpClient, type JsonObject} from '../CodexAcpClient'; import {CodexAppServerClient, type CodexConnectionEvent} from '../CodexAppServerClient'; import {startCodexConnection} from "../CodexJsonRpcConnection"; import {CodexAcpServer, type SessionState} from "../CodexAcpServer"; @@ -85,6 +85,7 @@ export interface ConnectionConfig { connection: MessageConnection; getExitCode: () => number | null; acpConnection?: AcpConnectionConfig; + codexConfig?: JsonObject; } export function createBaseTestFixture(config: ConnectionConfig): TestFixture { @@ -97,7 +98,7 @@ export function createBaseTestFixture(config: ConnectionConfig): TestFixture { }); const codexAppServerClient = new CodexAppServerClient(config.connection); - const codexAcpClient = new CodexAcpClient(codexAppServerClient); + const codexAcpClient = new CodexAcpClient(codexAppServerClient, config.codexConfig); const codexAcpAgent = new CodexAcpServer(acpConnection, codexAcpClient, undefined, config.getExitCode); const transportEvents: CodexConnectionEvent[] = []; @@ -255,7 +256,7 @@ export interface CodexMockTestFixture extends TestFixture { * Provides `sendServerRequest()` to simulate server-initiated requests (e.g., approval requests). * Provides `setPermissionResponse()` to control ACP permission dialog responses. */ -export function createCodexMockTestFixture(): CodexMockTestFixture { +export function createCodexMockTestFixture(codexConfig?: JsonObject): CodexMockTestFixture { let unhandledNotificationHandler: ((notification: any) => void) | null = null; const requestHandlers = new Map Promise>(); @@ -303,6 +304,7 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { const baseFixture = createBaseTestFixture({ connection: mockCodexConnection, getExitCode: () => null, + ...(codexConfig === undefined ? {} : {codexConfig}), acpConnection: { connection: acpConnection, events: acpConnectionEvents,