From 9e5af64d7de3110cf1dc14d1b8902fb92740541f Mon Sep 17 00:00:00 2001 From: "Pony.Ma" Date: Sat, 10 Oct 2026 22:54:50 +0800 Subject: [PATCH] fix: honor Codex config networking in workspace-write turns --- README.md | 4 +- readme-dev.md | 11 ++ src/CodexAcpClient.ts | 28 +++- .../CodexACPAgent/CodexAcpClient.test.ts | 24 +++- .../data/send-attachments-turn-start.json | 11 ++ .../CodexACPAgent/workspace-network.test.ts | 121 ++++++++++++++++++ 6 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/workspace-network.test.ts diff --git a/README.md b/README.md index 0c4ba667..97e7c07e 100644 --- a/README.md +++ b/README.md @@ -68,13 +68,15 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - `CODEX_API_KEY` - API key used when the API-key auth method is selected. Takes precedence over `OPENAI_API_KEY`. - `OPENAI_API_KEY` - fallback API key used when the API-key auth method is selected. - `CODEX_PATH` - run a specific Codex executable instead of the bundled package dependency. -- `CODEX_CONFIG` - JSON object merged into the Codex session config. +- `CODEX_CONFIG` - JSON object merged into the Codex session config. An explicit boolean `sandbox_workspace_write.network_access` also applies to workspace-write turns and takes precedence over `config.toml`. - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, or `agent-full-access`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. +Workspace-write modes (`agent` and `workspace-write`) honor `[sandbox_workspace_write] network_access = true` from Codex's effective configuration for the working directory. An explicit boolean in `CODEX_CONFIG` overrides that value. With neither setting, the mode keeps its existing network default. This changes only networking; writable roots, approvals, and the `read-only` and `agent-full-access` presets retain their policies. + ## Development ```bash diff --git a/readme-dev.md b/readme-dev.md index 458c263f..c3daf54d 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -15,6 +15,17 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `CODEX_ACP_APP_SERVER_CRASH_LIMIT` - how many crashes of the Codex app-server in the crash window stop its automatic restart (default `5`). - `CODEX_ACP_APP_SERVER_CRASH_WINDOW_MS` - the crash window in milliseconds (default `300000`, 5 minutes). +### Workspace-write networking + +For `agent` and `workspace-write` modes, enable networking with the existing Codex setting: + +```toml +[sandbox_workspace_write] +network_access = true +``` + +The adapter reads Codex's effective configuration for the session working directory on each workspace-write turn. An explicit boolean `sandbox_workspace_write.network_access` in `CODEX_CONFIG` takes precedence over the file configuration. If neither source sets it, the mode keeps its existing default. Other sandbox types, writable roots, temporary-directory flags, and approval policies are unchanged. + ### Quick start #### Develop on Windows? diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index f8fc9380..2cc15aae 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1166,12 +1166,16 @@ export class CodexAcpClient { if (shouldCancel?.()) { return null; } + const sandboxPolicy = await this.createTurnSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories, cwd); + if (shouldCancel?.()) { + return null; + } return await this.codexClient.runTurn({ threadId: request.sessionId, input: input, approvalPolicy: agentMode.approvalPolicy, approvalsReviewer: agentMode.approvalsReviewer, - sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories), + sandboxPolicy, summary: disableSummary ? "none" : "auto", effort: effort, model: modelId.model, @@ -1179,6 +1183,28 @@ export class CodexAcpClient { }, onTurnStarted); } + private async createTurnSandboxPolicy( + sandboxPolicy: SandboxPolicy, + additionalDirectories: string[], + cwd: string, + ): Promise { + const policy = addAdditionalDirectoriesToSandboxPolicy(sandboxPolicy, additionalDirectories); + if (policy.type !== "workspaceWrite") { + return policy; + } + + const workspaceConfig = this.config["sandbox_workspace_write"]; + let networkAccess = isJsonObject(workspaceConfig) ? workspaceConfig["network_access"] : undefined; + if (typeof networkAccess !== "boolean") { + const response = await this.codexClient.configRead({includeLayers: false, cwd}); + networkAccess = response?.config?.sandbox_workspace_write?.network_access; + } + return { + ...policy, + networkAccess: typeof networkAccess === "boolean" ? networkAccess : policy.networkAccess, + }; + } + async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise { await this.codexClient.threadSettingsUpdate({ threadId: sessionId, diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 043cc54e..98e35577 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -15,7 +15,7 @@ import { import type {ServerNotification} from "../../app-server"; import type {SessionState} from "../../CodexAcpServer"; import {AgentMode} from "../../AgentMode"; -import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; +import type {ConfigReadResponse, Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, TurnStartParams} from "../../app-server/v2"; import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; @@ -1500,6 +1500,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); fixture.getCodexAppServerClient().listSkills = vi.fn().mockResolvedValue({ data: [] }); + fixture.getCodexAppServerClient().configRead = vi.fn().mockResolvedValue({config: {}, origins: {}, layers: null}); fixture.getCodexAppServerClient().turnStart = vi.fn().mockResolvedValue({ turn: { id: "turn-id", items: [], status: "inProgress", error: null } }); @@ -1843,6 +1844,27 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy).not.toHaveBeenCalled(); }); + it('returns cancelled while workspace network configuration is still pending', async () => { + const {mockFixture, turnStartSpy} = setupPromptFixture(); + const configRead = deferred(); + const configReadSpy = vi.spyOn(mockFixture.getCodexAppServerClient(), "configRead") + .mockReturnValue(configRead.promise); + const controller = new AbortController(); + const promptPromise = mockFixture.getCodexAcpAgent().prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "Hello"}], + }, controller.signal); + + await vi.waitFor(() => expect(configReadSpy).toHaveBeenCalled()); + controller.abort(); + await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); + expect(turnStartSpy).not.toHaveBeenCalled(); + + configRead.resolve({config: {} as ConfigReadResponse["config"], origins: {}, layers: null}); + await flushAsyncWork(); + expect(turnStartSpy).not.toHaveBeenCalled(); + }); + it('should send attachments as prompt items', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); diff --git a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json index 9d217c0b..62b5ba37 100644 --- a/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json +++ b/src/__tests__/CodexACPAgent/data/send-attachments-turn-start.json @@ -1,3 +1,14 @@ +{ + "eventType": "request", + "method": "config/read", + "params": { + "includeLayers": false, + "cwd": "cwd" + } +} +{ + "eventType": "response" +} { "eventType": "request", "method": "turn/start", diff --git a/src/__tests__/CodexACPAgent/workspace-network.test.ts b/src/__tests__/CodexACPAgent/workspace-network.test.ts new file mode 100644 index 00000000..df876d64 --- /dev/null +++ b/src/__tests__/CodexACPAgent/workspace-network.test.ts @@ -0,0 +1,121 @@ +import {describe, expect, it, vi} from "vitest"; +import {AgentMode} from "../../AgentMode"; +import {CodexAcpClient} from "../../CodexAcpClient"; +import {ModelId} from "../../ModelId"; +import type {ConfigReadResponse, TurnCompletedNotification} from "../../app-server/v2"; +import {createCodexMockTestFixture} from "../acp-test-utils"; + +const completedTurn: TurnCompletedNotification = { + threadId: "session", + turn: { + id: "turn", + status: "completed", + items: [], + error: null, + itemsView: "notLoaded", + startedAt: null, + completedAt: null, + durationMs: null, + }, +}; + +function configResponse(networkAccess?: boolean): ConfigReadResponse { + return { + config: { + sandbox_workspace_write: networkAccess === undefined ? null : { + network_access: networkAccess, + writable_roots: null, + exclude_tmpdir_env_var: null, + exclude_slash_tmp: null, + }, + } as ConfigReadResponse["config"], + origins: {}, + layers: null, + }; +} + +function createFixture(inlineNetwork?: boolean | string) { + const fixture = createCodexMockTestFixture(); + const server = fixture.getCodexAppServerClient(); + vi.spyOn(server, "listSkills").mockResolvedValue({data: []}); + const configRead = vi.spyOn(server, "configRead").mockResolvedValue(configResponse()); + const runTurn = vi.spyOn(server, "runTurn").mockResolvedValue(completedTurn); + const client = new CodexAcpClient(server, inlineNetwork === undefined ? {} : { + sandbox_workspace_write: {network_access: inlineNetwork}, + }); + return { + configRead, + runTurn, + prompt: (mode = AgentMode.Agent, shouldCancel?: () => boolean) => client.sendPrompt( + {sessionId: "session", prompt: [{type: "text", text: "Hello"}]}, + mode, + ModelId.create("test-model", "medium"), + null, + false, + "/workspace", + ["/workspace/extra"], + undefined, + shouldCancel, + ), + }; +} + +describe("workspace-write network configuration", () => { + it.each([ + {name: "explicit session enable", inline: true, file: false, expected: true}, + {name: "explicit session disable", inline: false, file: true, expected: false}, + {name: "enabled config.toml", inline: undefined, file: true, expected: true}, + {name: "disabled config.toml", inline: undefined, file: false, expected: false}, + {name: "preset default", inline: undefined, file: undefined, expected: false}, + {name: "malformed inline flag", inline: "true", file: undefined, expected: false}, + ])("honors $name without widening filesystem access", async ({inline, file, expected}) => { + const fixture = createFixture(inline); + fixture.configRead.mockResolvedValue(configResponse(file)); + await fixture.prompt(); + + expect(fixture.runTurn.mock.calls[0]![0]).toMatchObject({ + approvalPolicy: AgentMode.Agent.approvalPolicy, + approvalsReviewer: AgentMode.Agent.approvalsReviewer, + sandboxPolicy: { + ...AgentMode.Agent.sandboxPolicy, + writableRoots: ["/workspace/extra"], + networkAccess: expected, + }, + }); + if (typeof inline === "boolean") { + expect(fixture.configRead).not.toHaveBeenCalled(); + } else { + expect(fixture.configRead).toHaveBeenCalledWith({includeLayers: false, cwd: "/workspace"}); + } + expect(AgentMode.Agent.sandboxPolicy).toHaveProperty("networkAccess", false); + }); + + it("applies resolved config to workspace access mode", async () => { + const fixture = createFixture(); + fixture.configRead.mockResolvedValue(configResponse(true)); + await fixture.prompt(AgentMode.WorkspaceWrite); + expect(fixture.runTurn.mock.calls[0]![0]).toMatchObject({ + approvalsReviewer: AgentMode.WorkspaceWrite.approvalsReviewer, + sandboxPolicy: {type: "workspaceWrite", networkAccess: true}, + }); + }); + + it.each([AgentMode.ReadOnly, AgentMode.AgentFullAccess])("preserves $id mode", async mode => { + const fixture = createFixture(true); + fixture.configRead.mockResolvedValue(configResponse(true)); + await fixture.prompt(mode); + expect(fixture.runTurn.mock.calls[0]![0].sandboxPolicy).toEqual(mode.sandboxPolicy); + expect(fixture.configRead).not.toHaveBeenCalled(); + }); + + it("does not start a turn cancelled while resolving file config", async () => { + const fixture = createFixture(); + let cancelled = false; + fixture.configRead.mockImplementation(async () => { + cancelled = true; + return configResponse(true); + }); + await expect(fixture.prompt(AgentMode.Agent, () => cancelled)).resolves.toBeNull(); + expect(fixture.runTurn).not.toHaveBeenCalled(); + }); +});