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
31 changes: 30 additions & 1 deletion src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
Expand Down
7 changes: 7 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ export class CodexAcpServer {
steering: {
supported: true,
},
codex: {
workspaceWriteConfig: {
version: 1,
networkAccess: true,
writableRoots: true,
},
},
},
};
}
Expand Down
119 changes: 119 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createTestFixture,
createTestModel,
createTestSessionState,
mockPromptTurn,
type TestFixture
} from "../acp-test-utils";
import type {ServerNotification} from "../../app-server";
Expand All @@ -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 }, () => {

Expand Down Expand Up @@ -848,6 +850,123 @@ describe('ACP server test', { timeout: 40_000 }, () => {
});
});

async function promptWithSandboxConfig(
codexConfig: JsonObject | undefined,
agentMode: AgentMode = AgentMode.Agent,
): Promise<TurnStartParams> {
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[] = [
Expand Down
7 changes: 7 additions & 0 deletions src/__tests__/CodexACPAgent/initialize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ describe('CodexACPAgent - initialize', () => {
steering: {
supported: true,
},
codex: {
workspaceWriteConfig: {
version: 1,
networkAccess: true,
writableRoots: true,
},
},
},
});
});
Expand Down
8 changes: 5 additions & 3 deletions src/__tests__/acp-test-utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -85,6 +85,7 @@ export interface ConnectionConfig {
connection: MessageConnection;
getExitCode: () => number | null;
acpConnection?: AcpConnectionConfig;
codexConfig?: JsonObject;
}

export function createBaseTestFixture(config: ConnectionConfig): TestFixture {
Expand All @@ -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[] = [];
Expand Down Expand Up @@ -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<string, (params: unknown) => Promise<unknown>>();

Expand Down Expand Up @@ -303,6 +304,7 @@ export function createCodexMockTestFixture(): CodexMockTestFixture {
const baseFixture = createBaseTestFixture({
connection: mockCodexConnection,
getExitCode: () => null,
...(codexConfig === undefined ? {} : {codexConfig}),
acpConnection: {
connection: acpConnection,
events: acpConnectionEvents,
Expand Down