From f757ba2c63197822f65719650130815238a3ceef 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 | 73 +++++++++++++---- src/CodexAcpClient.ts | 43 +++++++++- src/CodexAcpServer.ts | 29 +++++-- src/CodexAppServerClient.ts | 17 +++- src/CodexCommands.ts | 5 +- .../CodexACPAgent/CodexAcpClient.test.ts | 81 +++++++++++++++++++ .../session-config-options.test.ts | 78 +++++++++++++++++- src/__tests__/acp-test-utils.ts | 1 + 10 files changed, 302 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index 5246590a..d4526a4b 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, 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 aed9710c..c53651a5 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -1,23 +1,34 @@ -import type {AskForApproval, SandboxMode, SandboxPolicy} from "./app-server/v2"; +import type {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:"; export class AgentMode { readonly id: string; readonly name: string; readonly description: string; readonly approvalPolicy: AskForApproval; - 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, approval: AskForApproval, sandbox: SandboxPolicy, sandboxMode: SandboxMode) { + private constructor( + id: string, + name: string, + description: string, + approval: AskForApproval, + sandbox: SandboxPolicy | null, + sandboxMode: SandboxMode | null, + permissionProfileId: string | null = null, + ) { this.id = id; this.name = name; this.description = description; this.approvalPolicy = approval; this.sandboxPolicy = sandbox; this.sandboxMode = sandboxMode; // same as sandboxPolicy, need to look for + this.permissionProfileId = permissionProfileId; } static readonly ReadOnly = new AgentMode( @@ -64,14 +75,14 @@ export class AgentMode { }; } - 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 { return { id: MODE_CONFIG_ID, name: "Mode", @@ -79,7 +90,7 @@ export class AgentMode { category: "mode", type: "select", currentValue: this.id, - options: AgentMode.all().map(mode => ({ + options: availableModes.map(mode => ({ value: mode.id, name: mode.name, description: mode.description, @@ -87,21 +98,51 @@ export class AgentMode { }; } - static all(): AgentMode[] { - return [AgentMode.ReadOnly, AgentMode.Agent, AgentMode.AgentFullAccess]; + static all( + permissionProfiles: PermissionProfileSummary[] = [], + permissionProfileApprovalPolicy: AskForApproval = "on-request", + ): 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.`, + permissionProfileApprovalPolicy, + 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 d42199cb..3d7f7cb6 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -32,9 +32,11 @@ import {sanitizeMcpServerName} from "./McpServerName"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, + AskForApproval, GetAccountResponse, ListMcpServerStatusResponse, Model, + PermissionProfileSummary, ReviewTarget, SkillsListParams, SkillsListResponse, @@ -389,6 +391,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 ?? []), @@ -407,12 +410,16 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + 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 ?? []), @@ -436,12 +443,16 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, thread: historyResponse.thread, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + 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), @@ -462,6 +473,9 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + permissionProfiles, }; } @@ -607,6 +621,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; } @@ -770,11 +795,17 @@ export class CodexAcpClient { if (shouldCancel?.()) { return null; } + const permissionProfileId = agentMode.permissionProfileId; return await this.codexClient.runTurn({ threadId: request.sessionId, input: input, approvalPolicy: agentMode.approvalPolicy, - 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, @@ -970,12 +1001,22 @@ export type SessionMetadata = { modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: string[], + activePermissionProfileId?: string | null, + approvalPolicy?: AskForApproval, + 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 dc21a60a..cbac5f20 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -111,6 +111,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + availableAgentModes: AgentMode[], collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; @@ -551,13 +552,21 @@ 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, + ); 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, @@ -593,7 +602,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]; } @@ -914,7 +923,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(); } @@ -1267,7 +1276,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), ]; @@ -1440,13 +1449,21 @@ 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, + ); 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, @@ -1480,7 +1497,7 @@ export class CodexAcpServer { await this.availableCommands.publish(sessionState); 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 50a95e42..37630d1a 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -23,6 +23,8 @@ import type { McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, + PermissionProfileListParams, + PermissionProfileListResponse, ReviewStartParams, ReviewStartResponse, SkillsExtraRootsSetParams, @@ -268,11 +270,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); @@ -658,6 +660,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 }); } @@ -992,6 +998,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 1d5e86d2..21c33292 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -407,12 +407,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 d1df6999..c1459a9a 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", @@ -484,6 +485,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(); @@ -927,6 +967,47 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); }); + 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", + 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 ab669916..1fff484c 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,13 @@ 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", +) { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); @@ -48,6 +54,9 @@ 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: [ + expect.objectContaining({value: AgentMode.ReadOnly.id}), + expect.objectContaining({value: AgentMode.Agent.id}), + expect.objectContaining({value: AgentMode.AgentFullAccess.id}), + { + 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((modeOption as any).options).not.toEqual(expect.arrayContaining([ + expect.objectContaining({value: "permission-profile::workspace"}), + expect.objectContaining({value: "permission-profile:blocked"}), + ])); + expect(codexAcpAgent.getSessionState("session-id").agentMode.approvalPolicy).toBe("never"); + }); + 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]); @@ -154,6 +208,26 @@ describe("Session config options", () => { expect((modeOption as any).currentValue).toBe(AgentMode.ReadOnly.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 de992ccd..fc4b1508 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -384,6 +384,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,