From 17a938bc05f1fa88d9d8c226d3a4c2e1714a44d8 Mon Sep 17 00:00:00 2001 From: Tom Pansino <2768420+tpansino@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:35:46 -0700 Subject: [PATCH] feat: expose configured permission profiles as modes --- README.md | 4 +- readme-dev.md | 2 +- src/AgentMode.ts | 109 ++++++++++++++---- src/CodexAcpClient.ts | 48 +++++++- src/CodexAcpServer.ts | 31 ++++- src/CodexAppServerClient.ts | 17 ++- src/CodexCommands.ts | 5 +- .../CodexACPAgent/CodexAcpClient.test.ts | 82 +++++++++++++ .../session-config-options.test.ts | 89 +++++++++++++- src/__tests__/acp-test-utils.ts | 1 + 10 files changed, 348 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 2bda2a26..cc1d5deb 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] ## Features - ChatGPT, API key, and client-provided custom gateway authentication. -- Model, reasoning effort, fast mode, approval, and sandbox mode configuration. +- Model, reasoning effort, fast mode, approval, sandbox mode, and configured Codex permission profile selection. - Text prompts, embedded context, images, resource links, and additional workspace directories. - Shell command, file change, [permission request](docs/permission-extension.md), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. - Subagent launches as standard ACP tool calls, with Codex thread identity and activity details in namespaced `_meta.codex.subagent` metadata. @@ -54,7 +54,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - `CODEX_CONFIG` - JSON object merged into the Codex session config. - `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`, `agent`, or `agent-full-access`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, `agent-full-access`, or `permission-profile:`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. diff --git a/readme-dev.md b/readme-dev.md index bc147807..348917a2 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -9,7 +9,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `CODEX_CONFIG` - JSON object merged into the Codex session config. - `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`, `agent`, or `agent-full-access`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, `agent-full-access`, or `permission-profile:`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. diff --git a/src/AgentMode.ts b/src/AgentMode.ts index c8c1ad03..b6826d0c 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -1,7 +1,14 @@ -import type {ApprovalsReviewer, AskForApproval, SandboxMode, SandboxPolicy} from "./app-server/v2"; +import type { + ApprovalsReviewer, + AskForApproval, + PermissionProfileSummary, + SandboxMode, + SandboxPolicy, +} from "./app-server/v2"; import type {SessionConfigOption, SessionMode, SessionModeState} from "@agentclientprotocol/sdk"; export const MODE_CONFIG_ID = "mode"; +const PERMISSION_PROFILE_MODE_PREFIX = "permission-profile:"; type AgentModeKind = "plan" | "auto_review" | "standard" | "full_access"; @@ -9,21 +16,23 @@ export class AgentMode { readonly id: string; readonly name: string; readonly description: string; - readonly kind: AgentModeKind; + readonly kind: AgentModeKind | null; readonly approvalPolicy: AskForApproval; readonly approvalsReviewer: ApprovalsReviewer; - readonly sandboxPolicy: SandboxPolicy; - readonly sandboxMode: SandboxMode; + readonly sandboxPolicy: SandboxPolicy | null; + readonly sandboxMode: SandboxMode | null; + readonly permissionProfileId: string | null; private constructor( id: string, name: string, description: string, - kind: AgentModeKind, + kind: AgentModeKind | null, approvalPolicy: AskForApproval, approvalsReviewer: ApprovalsReviewer, - sandboxPolicy: SandboxPolicy, - sandboxMode: SandboxMode, + sandboxPolicy: SandboxPolicy | null, + sandboxMode: SandboxMode | null, + permissionProfileId: string | null = null, ) { this.id = id; this.name = name; @@ -33,6 +42,7 @@ export class AgentMode { this.approvalsReviewer = approvalsReviewer; this.sandboxPolicy = sandboxPolicy; this.sandboxMode = sandboxMode; // same as sandboxPolicy, need to look for + this.permissionProfileId = permissionProfileId; } static readonly ReadOnly = new AgentMode( @@ -85,18 +95,39 @@ export class AgentMode { id: this.id, name: this.name, description: this.description, - _meta: {kind: this.kind}, + ...(this.kind === null ? {} : {_meta: {kind: this.kind}}), }; } - toSessionModeState(): SessionModeState { + toSessionModeState(availableModes: AgentMode[] = AgentMode.all()): SessionModeState { return { - availableModes: AgentMode.all().map(mode => mode.toSessionMode()), + availableModes: availableModes.map(mode => mode.toSessionMode()), currentModeId: this.id }; } - toConfigOption(): SessionConfigOption { + toConfigOption(availableModes: AgentMode[] = AgentMode.all()): SessionConfigOption { + const toOptions = (modes: AgentMode[]) => modes.map(mode => ({ + value: mode.id, + name: mode.name, + description: mode.description, + ...(mode.kind === null ? {} : {_meta: {kind: mode.kind}}), + })); + const permissionProfileModes = availableModes.filter(mode => mode.permissionProfileId !== null); + const options = permissionProfileModes.length === 0 + ? toOptions(availableModes) + : [ + { + group: "sandbox-modes", + name: "Sandbox Modes", + options: toOptions(availableModes.filter(mode => mode.permissionProfileId === null)), + }, + { + group: "permission-profiles", + name: "Permission Profiles", + options: toOptions(permissionProfileModes), + }, + ]; return { id: MODE_CONFIG_ID, name: "Mode", @@ -104,30 +135,58 @@ export class AgentMode { category: "mode", type: "select", currentValue: this.id, - options: AgentMode.all().map(mode => ({ - value: mode.id, - name: mode.name, - description: mode.description, - _meta: {kind: mode.kind}, - })), + options, }; } - static all(): AgentMode[] { - return [AgentMode.ReadOnly, AgentMode.Agent, AgentMode.AgentFullAccess]; + static all( + permissionProfiles: PermissionProfileSummary[] = [], + permissionProfileApprovalPolicy: AskForApproval = "on-request", + permissionProfileApprovalsReviewer: ApprovalsReviewer = "user", + ): AgentMode[] { + const profileModes = permissionProfiles + .filter(profile => profile.allowed && !profile.id.startsWith(":")) + .map(profile => new AgentMode( + `${PERMISSION_PROFILE_MODE_PREFIX}${profile.id}`, + profile.id, + profile.description ?? `Use the ${profile.id} Codex permission profile.`, + null, + permissionProfileApprovalPolicy, + permissionProfileApprovalsReviewer, + null, + null, + profile.id, + )); + return [AgentMode.ReadOnly, AgentMode.Agent, AgentMode.AgentFullAccess, ...profileModes]; } - static find(modeId: string): AgentMode | null { - const match = AgentMode.all().find(m => m.id === modeId); + static find(modeId: string, availableModes: AgentMode[] = AgentMode.all()): AgentMode | null { + const match = availableModes.find(m => m.id === modeId); return match ?? null; } - static getInitialAgentMode(): AgentMode { + static getInitialAgentMode( + availableModes: AgentMode[] = AgentMode.all(), + activePermissionProfileId: string | null = null, + ): AgentMode { const predefinedAgentMode = process.env["INITIAL_AGENT_MODE"]; if (predefinedAgentMode) { - return AgentMode.find(predefinedAgentMode) ?? AgentMode.DEFAULT_AGENT_MODE; - } else { - return AgentMode.DEFAULT_AGENT_MODE; + return AgentMode.find(predefinedAgentMode, availableModes) ?? AgentMode.DEFAULT_AGENT_MODE; } + if (activePermissionProfileId !== null) { + const profileMode = availableModes.find(mode => mode.permissionProfileId === activePermissionProfileId); + if (profileMode) { + return profileMode; + } + } + switch (activePermissionProfileId) { + case ":read-only": + return AgentMode.ReadOnly; + case ":workspace": + return AgentMode.Agent; + case ":danger-full-access": + return AgentMode.AgentFullAccess; + } + return AgentMode.DEFAULT_AGENT_MODE; } } diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index bec75265..ef7ac590 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -32,9 +32,12 @@ import {sanitizeMcpServerName} from "./McpServerName"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, + ApprovalsReviewer, + AskForApproval, GetAccountResponse, ListMcpServerStatusResponse, Model, + PermissionProfileSummary, ReviewTarget, SkillsListParams, SkillsListResponse, @@ -463,6 +466,7 @@ export class CodexAcpClient { async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); + const permissionProfiles = await this.fetchPermissionProfiles(request.cwd); const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), @@ -481,12 +485,17 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, } } async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); + const permissionProfiles = await this.fetchPermissionProfiles(request.cwd); const response = await this.codexClient.threadResume({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []), @@ -510,12 +519,17 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, }; } async newSession(request: acp.NewSessionRequest): Promise { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); await this.refreshSkills(request.cwd, additionalDirectories); + const permissionProfiles = await this.fetchPermissionProfiles(request.cwd); const response = await this.codexClient.threadStart({ config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers), @@ -536,6 +550,10 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, }; } @@ -694,6 +712,17 @@ export class CodexAcpClient { return new Set(configuredMcpServers.flatMap(server => Object.keys(server))); } + private async fetchPermissionProfiles(projectPath: string): Promise { + const profiles: PermissionProfileSummary[] = []; + let cursor: string | null = null; + do { + const response = await this.codexClient.listPermissionProfiles({cwd: projectPath, cursor}); + profiles.push(...(response?.data ?? [])); + cursor = response?.nextCursor ?? null; + } while (cursor !== null); + return profiles; + } + getModelProvider(): string | null { return this.gatewayConfig?.modelProvider ?? this.modelProvider; } @@ -857,12 +886,18 @@ export class CodexAcpClient { if (shouldCancel?.()) { return null; } + const permissionProfileId = agentMode.permissionProfileId; return await this.codexClient.runTurn({ threadId: request.sessionId, input: input, approvalPolicy: agentMode.approvalPolicy, approvalsReviewer: agentMode.approvalsReviewer, - sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories), + ...(permissionProfileId + ? { + permissions: permissionProfileId, + runtimeWorkspaceRoots: [cwd, ...additionalDirectories], + } + : {sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy!, additionalDirectories)}), summary: disableSummary ? "none" : "auto", effort: effort, model: modelId.model, @@ -1239,12 +1274,23 @@ export type SessionMetadata = { modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], + activePermissionProfileId?: string | null, + approvalPolicy?: AskForApproval, + approvalsReviewer?: ApprovalsReviewer, + permissionProfiles?: PermissionProfileSummary[], } export type SessionMetadataWithThread = SessionMetadata & { thread: Thread, } +function readActivePermissionProfileId(response: unknown): string | null { + const activePermissionProfile = (response as { + activePermissionProfile?: {id?: unknown} | null; + }).activePermissionProfile; + return typeof activePermissionProfile?.id === "string" ? activePermissionProfile.id : null; +} + function buildPromptItems(prompt: acp.ContentBlock[]): UserInput[] { return prompt.map((block): UserInput | null => { switch (block.type) { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f3ebb373..45f2ad01 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -127,6 +127,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + availableAgentModes: AgentMode[], collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; @@ -606,13 +607,22 @@ export class CodexAcpServer { const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, "sessionId" in request); const currentModel = this.findCurrentModel(models, currentModelId); const currentModelSupportsFast = modelSupportsFast(currentModel); + const availableAgentModes = AgentMode.all( + sessionMetadata.permissionProfiles, + sessionMetadata.approvalPolicy, + sessionMetadata.approvalsReviewer, + ); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, availableModels: models, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], - agentMode: AgentMode.getInitialAgentMode(), + agentMode: AgentMode.getInitialAgentMode( + availableAgentModes, + sessionMetadata.activePermissionProfileId, + ), + availableAgentModes, collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, @@ -649,7 +659,7 @@ export class CodexAcpServer { this.publishCurrentGoalAsync(sessionState, sessionGeneration); } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); - const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); + const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(sessionState.availableAgentModes); return [sessionId, sessionModelState, sessionModeState]; } @@ -1090,7 +1100,7 @@ export class CodexAcpServer { } private applyModeChange(sessionState: SessionState, value: string): void { - const newMode = AgentMode.find(value); + const newMode = AgentMode.find(value, sessionState.availableAgentModes); if (!newMode) { throw RequestError.invalidParams(); } @@ -1443,7 +1453,7 @@ export class CodexAcpServer { private createSessionConfigOptions(sessionState: SessionState): Array { const currentModelId = ModelId.fromString(sessionState.currentModelId); const configOptions = [ - sessionState.agentMode.toConfigOption(), + sessionState.agentMode.toConfigOption(sessionState.availableAgentModes), createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model), ]; @@ -1623,13 +1633,22 @@ export class CodexAcpServer { const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, true); const currentModel = this.findCurrentModel(models, currentModelId); const currentModelSupportsFast = modelSupportsFast(currentModel); + const availableAgentModes = AgentMode.all( + sessionMetadata.permissionProfiles, + sessionMetadata.approvalPolicy, + sessionMetadata.approvalsReviewer, + ); const sessionState: SessionState = { sessionId: sessionId, currentModelId: currentModelId, availableModels: models, supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [], supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"], - agentMode: AgentMode.getInitialAgentMode(), + agentMode: AgentMode.getInitialAgentMode( + availableAgentModes, + sessionMetadata.activePermissionProfileId, + ), + availableAgentModes, collaborationMode: sessionMetadata.collaborationMode, currentTurnId: null, lastTokenUsage: null, @@ -1664,7 +1683,7 @@ export class CodexAcpServer { await this.publishAvailableCommands(sessionState, requestedSessionGeneration); await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); - const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); + const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(sessionState.availableAgentModes); return { sessionId: sessionId, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 0f802d68..4960d324 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -23,6 +23,8 @@ import type { McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, + PermissionProfileListParams, + PermissionProfileListResponse, ReviewStartParams, ReviewStartResponse, SkillsExtraRootsSetParams, @@ -270,11 +272,11 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "initialize", params: params }); } - async turnStart(params: TurnStartParams): Promise { + async turnStart(params: ExperimentalTurnStartParams): Promise { return await this.sendRequest({ method: "turn/start", params: params }); } - async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void): Promise { + async runTurn(params: ExperimentalTurnStartParams, onTurnStarted?: (turnId: string) => void): Promise { const capturedCompletions: Array = []; const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => { capturedCompletions.push(event); @@ -664,6 +666,10 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "model/list", params }); } + async listPermissionProfiles(params: PermissionProfileListParams): Promise { + return await this.sendRequest({ method: "permissionProfile/list", params }); + } + async skillsExtraRootsSet(params: SkillsExtraRootsSetParams): Promise { return await this.sendRequest({ method: "skills/extraRoots/set", params }); } @@ -998,6 +1004,13 @@ export interface ExperimentalThreadSettingsUpdateParams { }; } +export type ExperimentalTurnStartParams = TurnStartParams & { + // Codex 0.147 accepts a profile id on the wire even though its generated + // PermissionProfileSelectionParams type currently describes an object. + permissions?: string | null; + runtimeWorkspaceRoots?: string[] | null; +}; + type McpServerStartupSnapshot = { status: McpServerStartupState; error: string | null; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index e4cd16d4..46232e5e 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -412,12 +412,15 @@ export class CodexCommands { sessionState.lastTokenUsage, sessionState.modelContextWindow ); + const permissionLine = agentMode.permissionProfileId + ? `**Permission profile:** ${agentMode.permissionProfileId}` + : `**Sandbox:** ${agentMode.sandboxMode}`; const lines = [ `**Model:** ${sessionState.currentModelId}`, `**Directory:** ${sessionState.cwd}`, `**Approval:** ${agentMode.approvalPolicy}`, - `**Sandbox:** ${agentMode.sandboxMode}`, + permissionLine, `**Account:** ${accountText}`, `**Session:** \`${sessionState.sessionId}\``, ``, diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 67cd14b3..588a661e 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -87,6 +87,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { "account/login/start", "account/read", "account/updated", + "permissionProfile/list", "thread/start", "model/list", "thread/started", @@ -514,6 +515,45 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + it('loads every page of cwd-aware permission profiles for a new session', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpClient = mockFixture.getCodexAcpClient(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + const listPermissionProfilesSpy = vi.spyOn(codexAppServerClient, "listPermissionProfiles") + .mockResolvedValueOnce({ + data: [{id: ":workspace", description: null, allowed: true}], + nextCursor: "next-page", + }) + .mockResolvedValueOnce({ + data: [{id: "team-default", description: "Team default permissions", allowed: true}], + nextCursor: null, + }); + vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ + thread: {id: "thread-id"} as any, + model: "gpt-5", + modelProvider: "openai", + reasoningEffort: "medium", + serviceTier: null, + activePermissionProfile: {id: "team-default", extends: ":workspace"}, + } as any); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [createTestModel({id: "gpt-5"})], + nextCursor: null, + }); + + const session = await codexAcpClient.newSession({cwd: "/workspace", mcpServers: []}); + + expect(listPermissionProfilesSpy).toHaveBeenNthCalledWith(1, {cwd: "/workspace", cursor: null}); + expect(listPermissionProfilesSpy).toHaveBeenNthCalledWith(2, {cwd: "/workspace", cursor: "next-page"}); + expect(session.activePermissionProfileId).toBe("team-default"); + expect(session.permissionProfiles).toEqual([ + {id: ":workspace", description: null, allowed: true}, + {id: "team-default", description: "Team default permissions", allowed: true}, + ]); + }); + it('applies ACP additional directories to resumed and loaded sessions explicitly', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpClient = mockFixture.getCodexAcpClient(); @@ -959,6 +999,48 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(turnStartSpy.mock.calls[0]![0].approvalsReviewer).toBe("auto_review"); }); + it('uses a custom permission profile without a legacy sandbox policy', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + const availableAgentModes = AgentMode.all([{ + id: "team-default", + description: "Team default permissions", + allowed: true, + }]); + const permissionProfileMode = availableAgentModes.find(mode => mode.permissionProfileId === "team-default")!; + + vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); + const turnStartSpy = vi.spyOn(codexAppServerClient, "turnStart").mockResolvedValue({ + turn: {id: "turn-id", items: [], status: "inProgress", error: null} + } as any); + vi.spyOn(codexAppServerClient, "awaitTurnCompleted").mockResolvedValue({ + threadId: "session-id", + turn: {id: "turn-id", items: [], status: "completed", error: null} + } as any); + vi.spyOn(codexAcpAgent, "getSessionState").mockReturnValue(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + additionalDirectories: ["/workspace/extra"], + agentMode: permissionProfileMode, + availableAgentModes, + })); + + await codexAcpAgent.prompt({ + sessionId: "session-id", + prompt: [{type: "text", text: "Hello"}], + }); + + expect(turnStartSpy.mock.calls[0]![0]).toMatchObject({ + approvalPolicy: "on-request", + approvalsReviewer: "user", + permissions: "team-default", + runtimeWorkspaceRoots: ["/workspace", "/workspace/extra"], + }); + expect(turnStartSpy.mock.calls[0]![0]).not.toHaveProperty("sandboxPolicy"); + }); + 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/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index e895d282..41aaefea 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -5,7 +5,7 @@ import { MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, } from "../../ModelConfigOption"; -import type {Model, ReasoningEffortOption} from "../../app-server/v2"; +import type {Model, PermissionProfileSummary, ReasoningEffortOption} from "../../app-server/v2"; import {LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; import { COLLABORATION_MODE_CONFIG_ID, @@ -35,7 +35,14 @@ function buildModels(): {fast: Model; slow: Model} { return {fast, slow}; } -async function createSession(currentModelId: string, availableModels: Array) { +async function createSession( + currentModelId: string, + availableModels: Array, + permissionProfiles: PermissionProfileSummary[] = [], + activePermissionProfileId: string | null = null, + approvalPolicy: "on-request" | "never" = "on-request", + approvalsReviewer: "user" | "auto_review" = "user", +) { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); @@ -48,6 +55,10 @@ async function createSession(currentModelId: string, availableModels: Array { expect(codexAcpAgent.getSessionState("session-id").currentModelId).toBe("custom-model[high]"); }); + it("exposes allowed custom permission profiles as modes and selects the active profile", async () => { + const {fast} = buildModels(); + const permissionProfiles: PermissionProfileSummary[] = [ + {id: ":workspace", description: "Built-in workspace", allowed: true}, + {id: "blocked", description: "Blocked by requirements", allowed: false}, + {id: "team-default", description: "Team default permissions", allowed: true}, + ]; + const {codexAcpAgent, response} = await createSession( + "fast-model[medium]", + [fast], + permissionProfiles, + "team-default", + "never", + ); + + const expectedModeId = "permission-profile:team-default"; + const modeOption = response.configOptions?.find(option => option.id === MODE_CONFIG_ID); + expect(modeOption).toMatchObject({ + currentValue: expectedModeId, + options: [ + { + group: "sandbox-modes", + name: "Sandbox Modes", + options: [ + expect.objectContaining({value: AgentMode.ReadOnly.id}), + expect.objectContaining({value: AgentMode.Agent.id}), + expect.objectContaining({value: AgentMode.AgentFullAccess.id}), + ], + }, + { + group: "permission-profiles", + name: "Permission Profiles", + options: [{ + value: expectedModeId, + name: "team-default", + description: "Team default permissions", + }], + }, + ], + }); + expect(response.modes).toMatchObject({ + currentModeId: expectedModeId, + availableModes: expect.arrayContaining([{ + id: expectedModeId, + name: "team-default", + description: "Team default permissions", + }]), + }); + expect(JSON.stringify((modeOption as any).options)).not.toContain("permission-profile::workspace"); + expect(JSON.stringify((modeOption as any).options)).not.toContain("permission-profile:blocked"); + expect(codexAcpAgent.getSessionState("session-id").agentMode.approvalPolicy).toBe("never"); + expect(codexAcpAgent.getSessionState("session-id").agentMode.approvalsReviewer).toBe("user"); + }); + it("keeps the legacy models list as combined model/effort entries", async () => { const {fast, slow} = buildModels(); const {response} = await createSession("fast-model[medium]", [fast, slow]); @@ -171,6 +236,26 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.Agent.id); }); + it("changes to a custom permission profile via setSessionConfigOption", async () => { + const {fast} = buildModels(); + const {codexAcpAgent} = await createSession("fast-model[medium]", [fast], [{ + id: "team-default", + description: "Team default permissions", + allowed: true, + }]); + + const result = await codexAcpAgent.setSessionConfigOption({ + sessionId: "session-id", + configId: MODE_CONFIG_ID, + value: "permission-profile:team-default", + }); + + expect(codexAcpAgent.getSessionState("session-id").agentMode.permissionProfileId).toBe("team-default"); + expect(result.configOptions?.find(option => option.id === MODE_CONFIG_ID)).toMatchObject({ + currentValue: "permission-profile:team-default", + }); + }); + it("changes collaboration mode without starting a model turn", async () => { const {fast} = buildModels(); const {codexAcpAgent, codexAcpClient} = await createSession("fast-model[medium]", [fast]); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 6b9e20c0..b6bfa860 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -396,6 +396,7 @@ export function createTestSessionState(overrides?: Partial): Sessi supportedReasoningEfforts: [], supportedInputModalities: ["text", "image"], agentMode: AgentMode.DEFAULT_AGENT_MODE, + availableAgentModes: AgentMode.all(), collaborationMode: DEFAULT_COLLABORATION_MODE, fastModeEnabled: false, currentModelSupportsFast: false,