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
4 changes: 2 additions & 2 deletions docs/permission-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ When custom profiles exist, the config option groups entries under **Sandbox Mod

## Initial and restored modes

New, loaded, resumed, and forked sessions select the active custom profile reported by Codex when that profile is available. Built-in or unavailable active profiles keep the adapter's default `agent` mode (Auto review).
New, loaded, resumed, and forked sessions select the active custom profile reported by Codex when that profile is available. Otherwise the starting mode is the built-in mode that reproduces the sandbox and approval policy Codex resolved for the thread: `dangerFullAccess` with `never` selects `agent-full-access`, `readOnly` with `on-request` selects `read-only`, and `workspaceWrite` with `on-request` selects `agent`. A resolved pair that no built-in mode represents keeps the adapter's default `agent` mode (Auto review).

`INITIAL_AGENT_MODE` overrides the active profile. Set it to an available mode ID, including `permission-profile:<profile-id>`. An unavailable override falls back to the default mode.
`INITIAL_AGENT_MODE` overrides both the active profile and the resolved policy. Set it to an available mode ID, including `permission-profile:<profile-id>`. An unavailable override falls back to the default mode.

## Applying permissions

Expand Down
38 changes: 38 additions & 0 deletions src/AgentMode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ const PERMISSION_PROFILE_MODE_PREFIX = "permission-profile:";

type AgentModeKind = "plan" | "auto_review" | "standard" | "full_access";

/**
* The permissions Codex resolved for a thread, as reported by `thread/start`,
* `thread/resume`, and `thread/fork`. A field is null when the response omitted it.
*/
export type ResolvedPermissions = {
approvalPolicy: AskForApproval | null;
sandboxPolicy: SandboxPolicy | null;
};

export class AgentMode {
readonly id: string;
readonly name: string;
Expand Down Expand Up @@ -193,6 +202,7 @@ export class AgentMode {
static getInitialAgentMode(
availableModes: AgentMode[] = AgentMode.all(),
activePermissionProfileId: string | null = null,
resolvedPermissions: ResolvedPermissions | null = null,
): AgentMode {
const predefinedAgentMode = process.env["INITIAL_AGENT_MODE"];
if (predefinedAgentMode) {
Expand All @@ -204,6 +214,34 @@ export class AgentMode {
return profileMode;
}
}
if (resolvedPermissions !== null) {
const resolvedMode = AgentMode.fromResolvedPermissions(resolvedPermissions, availableModes);
if (resolvedMode !== null) {
return resolvedMode;
}
}
return AgentMode.DEFAULT_AGENT_MODE;
}

/** The built-in mode that matches the sandbox and approval policy Codex resolved for a thread, or null when none does. */
private static fromResolvedPermissions(
{approvalPolicy, sandboxPolicy}: ResolvedPermissions,
availableModes: AgentMode[],
): AgentMode | null {
const find = (mode: AgentMode): AgentMode | null =>
availableModes.find(candidate => candidate.id === mode.id) ?? null;
if (sandboxPolicy === null) {
return null;
}
switch (sandboxPolicy.type) {
case "dangerFullAccess":
return approvalPolicy === "never" ? find(AgentMode.AgentFullAccess) : null;
case "readOnly":
return approvalPolicy === "on-request" ? find(AgentMode.ReadOnly) : null;
case "workspaceWrite":
return approvalPolicy === "on-request" ? find(AgentMode.Agent) : null;
case "externalSandbox":
return null;
}
}
}
15 changes: 14 additions & 1 deletion src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
import type {ServiceTier} from "./app-server/ServiceTier";
import type {JsonValue} from "./app-server/serde_json/JsonValue";
import {ModelId} from "./ModelId";
import {AgentMode} from "./AgentMode";
import {AgentMode, type ResolvedPermissions} from "./AgentMode";
import path from "node:path";
import {isDeepStrictEqual} from "node:util";
import {logger} from "./Logger";
Expand Down Expand Up @@ -113,6 +113,8 @@ type ResumedThread = {
materialized: boolean;
/** The mode that the resume response reports. Null when the thread has no rollout yet. */
collaborationMode: ModeKind | null;
/** The permissions the resume response reports. Null when `thread/read` answered instead. */
resolvedPermissions: ResolvedPermissions | null;
};

/**
Expand Down Expand Up @@ -653,6 +655,10 @@ export class CodexAcpClient {
itemsBackwardsCursor: response.itemsBackwardsCursor ?? null,
materialized: true,
collaborationMode: response.collaborationMode?.mode ?? null,
resolvedPermissions: {
approvalPolicy: response.approvalPolicy ?? null,
sandboxPolicy: response.sandbox ?? null,
},
};
} catch (err) {
if (isThreadActiveWriterError(err)) throw threadActiveWriterRequestError(params.threadId, err);
Expand Down Expand Up @@ -680,6 +686,7 @@ export class CodexAcpClient {
itemsBackwardsCursor: null,
materialized: false,
collaborationMode: null,
resolvedPermissions: null,
};
}
}
Expand Down Expand Up @@ -720,6 +727,7 @@ export class CodexAcpClient {
skippedMcpServers,
...(instructions ? {instructions} : {}),
activePermissionProfileId: response.materialized ? response.activePermissionProfileId : defaultPermissionProfileId ?? null,
...(response.resolvedPermissions ? {resolvedPermissions: response.resolvedPermissions} : {}),
...permissionProfileMetadata,
}
}
Expand Down Expand Up @@ -805,6 +813,7 @@ export class CodexAcpClient {
skippedMcpServers,
...(instructions ? {instructions} : {}),
activePermissionProfileId: response.materialized ? response.activePermissionProfileId : defaultPermissionProfileId ?? null,
...(response.resolvedPermissions ? {resolvedPermissions: response.resolvedPermissions} : {}),
...permissionProfileMetadata,
};
}
Expand Down Expand Up @@ -888,6 +897,10 @@ export class CodexAcpClient {
skippedMcpServers,
...(instructions ? {instructions} : {}),
activePermissionProfileId: response.activePermissionProfile?.id ?? null,
resolvedPermissions: {
approvalPolicy: response.approvalPolicy ?? null,
sandboxPolicy: response.sandbox ?? null,
},
...permissionProfileMetadata,
};
}
Expand Down
2 changes: 2 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,7 @@ export class CodexAcpServer {
agentMode: AgentMode.getInitialAgentMode(
availableAgentModes,
sessionMetadata.activePermissionProfileId,
sessionMetadata.resolvedPermissions ?? null,
),
availableAgentModes,
collaborationMode: sessionMetadata.collaborationMode,
Expand Down Expand Up @@ -3630,6 +3631,7 @@ export class CodexAcpServer {
agentMode: AgentMode.getInitialAgentMode(
availableAgentModes,
sessionMetadata.activePermissionProfileId,
sessionMetadata.resolvedPermissions ?? null,
),
availableAgentModes,
collaborationMode: sessionMetadata.collaborationMode,
Expand Down
4 changes: 4 additions & 0 deletions src/SessionFork.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,10 @@ export async function forkSession(
additionalDirectories,
skippedMcpServers,
activePermissionProfileId: response.activePermissionProfile?.id ?? null,
resolvedPermissions: {
approvalPolicy: response.approvalPolicy ?? null,
sandboxPolicy: response.sandbox ?? null,
},
...permissionProfileMetadata,
...(instructions ? {instructions} : {}),
};
Expand Down
3 changes: 3 additions & 0 deletions src/SessionMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ThreadItemEntry,
ThreadStatus,
} from "./app-server/v2";
import type {ResolvedPermissions} from "./AgentMode";
import type {SessionInstructions} from "./ClientSystemPrompt";

export type SessionMetadata = {
Expand All @@ -23,6 +24,8 @@ export type SessionMetadata = {
instructions?: SessionInstructions,
sessionTitle?: string | null,
activePermissionProfileId?: string | null,
/** The permissions Codex resolved for the thread; absent when its response reported none. */
resolvedPermissions?: ResolvedPermissions,
approvalPolicy?: AskForApproval,
approvalsReviewer?: ApprovalsReviewer,
permissionProfiles?: PermissionProfileSummary[],
Expand Down
47 changes: 47 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,53 @@ describe('ACP server test', { timeout: 40_000 }, () => {
expect(codexAcpAgent.getSessionState("thread-id").agentMode).toMatchObject(expected);
});

function createResolvedPermissionsFixture() {
vi.stubEnv("INITIAL_AGENT_MODE", undefined);
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
const codexAcpAgent = mockFixture.getCodexAcpAgent();
const codexAppServerClient = mockFixture.getCodexAppServerClient();

vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null});
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []});
vi.spyOn(codexAppServerClient, "configRead").mockResolvedValue({config: {}} as any);
vi.spyOn(codexAppServerClient, "listPermissionProfiles").mockResolvedValue({data: [], nextCursor: null});
const threadResponse = {
thread: {id: "thread-id", turns: [], status: {type: "idle"}},
model: "gpt-5",
modelProvider: "openai",
reasoningEffort: "medium",
serviceTier: null,
activePermissionProfile: null,
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: {type: "dangerFullAccess"},
collaborationMode: null,
itemsBackwardsCursor: null,
};
vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue(threadResponse as any);
vi.spyOn(codexAppServerClient, "threadResume").mockResolvedValue(threadResponse as any);
vi.spyOn(codexAppServerClient, "threadFork").mockResolvedValue(threadResponse as any);
vi.spyOn(codexAppServerClient, "threadReadWithHistory").mockResolvedValue({thread: threadResponse.thread} as any);
vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({
data: [createTestModel({id: "gpt-5"})],
nextCursor: null,
});
return codexAcpAgent;
}

it.each([
"newSession", "loadSession", "resumeSession", "forkSession",
] as const)('starts %s in the mode of the permissions Codex resolved', async method => {
const codexAcpAgent = createResolvedPermissionsFixture();

const session = await codexAcpAgent[method]({cwd: "/workspace", mcpServers: [], sessionId: "thread-id"});

expect(session.modes?.currentModeId).toBe(AgentMode.AgentFullAccess.id);
expect(codexAcpAgent.getSessionState("thread-id").agentMode).toBe(AgentMode.AgentFullAccess);
});

it('applies ACP additional directories to resumed and loaded sessions explicitly', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpClient = mockFixture.getCodexAcpClient();
Expand Down
98 changes: 96 additions & 2 deletions src/__tests__/CodexACPAgent/session-config-options.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {afterEach, describe, expect, it, vi} from "vitest";
import {afterEach, beforeEach, describe, expect, it, vi} from "vitest";
import * as acp from "@agentclientprotocol/sdk/unstable";
import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils";
import {AgentMode, MODE_CONFIG_ID} from "../../AgentMode";
import {AgentMode, MODE_CONFIG_ID, type ResolvedPermissions} from "../../AgentMode";
import {
MODEL_CONFIG_ID,
REASONING_EFFORT_CONFIG_ID,
Expand Down Expand Up @@ -47,6 +47,7 @@ async function createSession(
activePermissionProfileId?: string | null;
approvalPolicy?: "on-request" | "never";
approvalsReviewer?: "user" | "auto_review";
resolvedPermissions?: ResolvedPermissions;
} = {},
) {
const {
Expand All @@ -56,6 +57,7 @@ async function createSession(
activePermissionProfileId = null,
approvalPolicy = "on-request",
approvalsReviewer = "user",
resolvedPermissions,
} = options;
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
Expand All @@ -73,6 +75,7 @@ async function createSession(
approvalPolicy,
approvalsReviewer,
permissionProfiles,
...(resolvedPermissions ? {resolvedPermissions} : {}),
});

if (clientCapabilities) {
Expand Down Expand Up @@ -298,6 +301,97 @@ describe("Session config options", () => {
},
);

describe("initial mode from the permissions Codex resolved", () => {
beforeEach(() => {
vi.stubEnv("INITIAL_AGENT_MODE", undefined);
});

it.each<{
case: string;
resolvedPermissions: ResolvedPermissions;
expectedModeId: string;
}>([
{
case: "danger-full-access with no approvals",
resolvedPermissions: {
approvalPolicy: "never",
sandboxPolicy: {type: "dangerFullAccess"},
},
expectedModeId: AgentMode.AgentFullAccess.id,
},
{
case: "read-only with on-request approvals",
resolvedPermissions: {
approvalPolicy: "on-request",
sandboxPolicy: {type: "readOnly", networkAccess: false},
},
expectedModeId: AgentMode.ReadOnly.id,
},
{
case: "workspace-write with on-request approvals",
resolvedPermissions: {
approvalPolicy: "on-request",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: [],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
},
expectedModeId: AgentMode.Agent.id,
},
{
case: "a pair no built-in mode represents",
resolvedPermissions: {
approvalPolicy: "never",
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: [],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
},
expectedModeId: AgentMode.DEFAULT_AGENT_MODE.id,
},
])("starts a session from $case", async ({resolvedPermissions, expectedModeId}) => {
const {fast} = buildModels();
const {response} = await createSession("fast-model[medium]", [fast], {resolvedPermissions});

expect(response.modes?.currentModeId).toBe(expectedModeId);
expect(response.configOptions?.find(option => option.id === MODE_CONFIG_ID)?.currentValue)
.toBe(expectedModeId);
});

it("keeps INITIAL_AGENT_MODE ahead of the resolved permissions", async () => {
vi.stubEnv("INITIAL_AGENT_MODE", AgentMode.ReadOnly.id);
const {fast} = buildModels();
const {response} = await createSession("fast-model[medium]", [fast], {
resolvedPermissions: {
approvalPolicy: "never",
sandboxPolicy: {type: "dangerFullAccess"},
},
});

expect(response.modes?.currentModeId).toBe(AgentMode.ReadOnly.id);
});

it("keeps the active permission profile ahead of the resolved permissions", async () => {
const {fast} = buildModels();
const {response} = await createSession("fast-model[medium]", [fast], {
activePermissionProfileId: "team-default",
permissionProfiles: [{id: "team-default", description: null, allowed: true}],
resolvedPermissions: {
approvalPolicy: "never",
sandboxPolicy: {type: "dangerFullAccess"},
},
});

expect(response.modes?.currentModeId).toBe("permission-profile:team-default");
});
});

it.each([
{initialMode: "permission-profile:team-default", expectedMode: "permission-profile:team-default"},
{initialMode: "permission-profile:blocked", expectedMode: AgentMode.DEFAULT_AGENT_MODE.id},
Expand Down
Loading