From d419f4168c0748d501f2758be0d0c0b01ca2b93e 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 | 121 +++++++++++++---- src/CodexAcpClient.ts | 53 +++++++- src/CodexAcpServer.ts | 40 +++++- src/CodexAppServerClient.ts | 16 ++- src/CodexCommands.ts | 5 +- src/SessionMetadata.ts | 13 +- .../CodexACPAgent/CodexAcpClient.test.ts | 82 ++++++++++++ .../session-config-options.test.ts | 124 ++++++++++++++++-- src/__tests__/acp-test-utils.ts | 1 + 11 files changed, 410 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 0c4ba6678..e9fc7d6c3 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. - Concrete recommended model and reasoning-effort values through the opt-in [AIR recommended config values](docs/air-extensions.md#recommended-config-values) capability. - Text prompts, embedded context, images, resource links, and additional workspace directories. - Shell command, file change, [permission request](docs/air-extensions.md#permission-presentation), MCP tool call, terminal output, reasoning, plan, web search, image generation, image view, token usage, and review events. @@ -71,7 +71,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`, `workspace-write`, `agent`, or `agent-full-access`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `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 458c263fc..156a7b95e 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. `hooks` move to App Server startup (`-c hooks=...`) for AIR review; Codex rejects malformed hooks at startup and `initialize` reports its error. - `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`. +- `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `workspace-write`, `agent`, `agent-full-access`, or `permission-profile:`. - `NO_BROWSER` - hide browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. - `CODEX_ACP_APP_SERVER_CRASH_LIMIT` - how many crashes of the Codex app-server in the crash window stop its automatic restart (default `5`). diff --git a/src/AgentMode.ts b/src/AgentMode.ts index 68a0f9235..178a9734b 100644 --- a/src/AgentMode.ts +++ b/src/AgentMode.ts @@ -1,8 +1,15 @@ -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"; import {AIR_KIND_KEY, airOnlyMeta} from "./AirExtension"; export const MODE_CONFIG_ID = "mode"; +const PERMISSION_PROFILE_MODE_PREFIX = "permission-profile:"; type AgentModeKind = "plan" | "auto_review" | "standard" | "full_access"; @@ -10,21 +17,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; @@ -34,6 +43,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( @@ -96,7 +106,7 @@ export class AgentMode { /** Only AIR gets the mode kind, in `_meta.jetbrains.air.kind`. */ toSessionMode(airClient: boolean): SessionMode { - const meta = airOnlyMeta(airClient, AIR_KIND_KEY, this.kind); + const meta = this.kind === null ? undefined : airOnlyMeta(airClient, AIR_KIND_KEY, this.kind); return { id: this.id, name: this.name, @@ -105,14 +115,38 @@ export class AgentMode { }; } - toSessionModeState(airClient: boolean): SessionModeState { + toSessionModeState(airClient: boolean, availableModes: AgentMode[] = AgentMode.all()): SessionModeState { return { - availableModes: AgentMode.all().map(mode => mode.toSessionMode(airClient)), + availableModes: availableModes.map(mode => mode.toSessionMode(airClient)), currentModeId: this.id }; } - toConfigOption(airClient: boolean): SessionConfigOption { + toConfigOption(airClient: boolean, availableModes: AgentMode[] = AgentMode.all()): SessionConfigOption { + const toOptions = (modes: AgentMode[]) => modes.map(mode => { + const meta = mode.kind === null ? undefined : airOnlyMeta(airClient, AIR_KIND_KEY, mode.kind); + return { + value: mode.id, + name: mode.name, + description: mode.description, + ...(meta ? {_meta: meta} : {}), + }; + }); + 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", @@ -120,33 +154,64 @@ export class AgentMode { category: "mode", type: "select", currentValue: this.id, - options: AgentMode.all().map(mode => { - const meta = airOnlyMeta(airClient, AIR_KIND_KEY, mode.kind); - return { - value: mode.id, - name: mode.name, - description: mode.description, - ...(meta ? {_meta: meta} : {}), - }; - }), + options, }; } - static all(): AgentMode[] { - return [AgentMode.ReadOnly, AgentMode.WorkspaceWrite, 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.WorkspaceWrite, + 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 f8fc9380e..637228a69 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -34,6 +34,8 @@ import {listedSessionTitle} from "./SessionTitle"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, + ApprovalsReviewer, + AskForApproval, GetAccountRateLimitsResponse, GetAccountResponse, ListMcpServerStatusParams, @@ -42,6 +44,7 @@ import type { McpServerOauthLoginParams, McpServerOauthLoginResponse, Model, + PermissionProfileSummary, ReviewTarget, SkillsListParams, SkillsListResponse, @@ -83,6 +86,9 @@ type ResumedThread = { modelProvider: string; reasoningEffort: ReasoningEffort | null; serviceTier: string | null; + activePermissionProfileId: string | null; + approvalPolicy: AskForApproval | undefined; + approvalsReviewer: ApprovalsReviewer | undefined; itemsBackwardsCursor: string | null; materialized: boolean; /** The mode that the resume response reports. Null when the thread has no rollout yet. */ @@ -639,6 +645,9 @@ export class CodexAcpClient { modelProvider: response.modelProvider, reasoningEffort: response.reasoningEffort, serviceTier: response.serviceTier, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, itemsBackwardsCursor: response.itemsBackwardsCursor ?? null, materialized: true, collaborationMode: response.collaborationMode?.mode ?? null, @@ -661,6 +670,9 @@ export class CodexAcpClient { modelProvider: response.thread.modelProvider, reasoningEffort: response.thread.reasoningEffort, serviceTier: null, + activePermissionProfileId: null, + approvalPolicy: undefined, + approvalsReviewer: undefined, // An unmaterialized thread has no persisted history to hydrate: // `thread/turns/list` rejects it outright ("not materialized // yet"), and there is nothing to list either way. @@ -674,6 +686,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 sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); const response = await this.resumeThread({ @@ -696,6 +709,10 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, + activePermissionProfileId: response.activePermissionProfileId, + ...(response.approvalPolicy === undefined ? {} : {approvalPolicy: response.approvalPolicy}), + ...(response.approvalsReviewer === undefined ? {} : {approvalsReviewer: response.approvalsReviewer}), + permissionProfiles, } } @@ -721,6 +738,7 @@ export class CodexAcpClient { 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 sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []); const response = await this.resumeThread({ @@ -759,6 +777,10 @@ export class CodexAcpClient { history, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, + activePermissionProfileId: response.activePermissionProfileId, + ...(response.approvalPolicy === undefined ? {} : {approvalPolicy: response.approvalPolicy}), + ...(response.approvalsReviewer === undefined ? {} : {approvalsReviewer: response.approvalsReviewer}), + permissionProfiles, }; } @@ -792,6 +814,7 @@ export class CodexAcpClient { const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta); const developerInstructions = airCustomInstructions(request._meta); await this.refreshSkills(request.cwd, additionalDirectories); + const permissionProfiles = await this.fetchPermissionProfiles(request.cwd); const sessionConfig = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers); const response = await this.codexClient.threadStart({ @@ -815,6 +838,10 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, skippedMcpServers: sessionConfig.skippedMcpServers, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, }; } @@ -1012,6 +1039,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; } @@ -1166,12 +1204,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, @@ -1359,6 +1403,13 @@ export type SessionConfig = { skippedMcpServers: string[], } +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 d3f90a8bb..5e968c44d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -198,6 +198,7 @@ export interface SessionState extends SessionIndexTitleState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + availableAgentModes: AgentMode[], collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; @@ -906,13 +907,22 @@ export class CodexAcpServer { const sessionMcpServers = this.resolveSessionMcpServers(requestedMcpServers, operation === "resume"); 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, @@ -985,7 +995,10 @@ export class CodexAcpServer { } const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = - sessionState.agentMode.toSessionModeState(sessionState.clientCapabilities.airClient); + sessionState.agentMode.toSessionModeState( + sessionState.clientCapabilities.airClient, + sessionState.availableAgentModes, + ); return [sessionId, sessionModelState, sessionModeState]; } @@ -1912,7 +1925,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(); } @@ -2280,7 +2293,10 @@ export class CodexAcpServer { ? sessionState.availableModels.find(model => model.isDefault)?.id : undefined; const configOptions = [ - sessionState.agentMode.toConfigOption(sessionState.clientCapabilities.airClient), + sessionState.agentMode.toConfigOption( + sessionState.clientCapabilities.airClient, + sessionState.availableAgentModes, + ), createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId), ]; @@ -2486,13 +2502,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, @@ -2549,7 +2574,10 @@ export class CodexAcpServer { await this.publishCurrentGoalBestEffort(sessionState, requestedSessionGeneration, true); const sessionModelState: LegacySessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = - sessionState.agentMode.toSessionModeState(sessionState.clientCapabilities.airClient); + sessionState.agentMode.toSessionModeState( + sessionState.clientCapabilities.airClient, + sessionState.availableAgentModes, + ); return { sessionId: sessionId, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 9a1f95c8d..0d64715fb 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -33,6 +33,8 @@ import type { McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, + PermissionProfileListParams, + PermissionProfileListResponse, ReviewStartParams, ReviewStartResponse, SkillsExtraRootsSetParams, @@ -303,11 +305,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); @@ -888,6 +890,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 }); } @@ -1251,6 +1257,12 @@ 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; +}; function isMcpServerStatusUpdatedNotification(notification: ServerNotification): notification is { method: "mcpServer/startupStatus/updated"; params: McpServerStatusUpdatedNotification; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index afdb53ddc..87cee02bd 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -450,12 +450,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/SessionMetadata.ts b/src/SessionMetadata.ts index 8e74a7edf..6705f0489 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -1,6 +1,13 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; -import type {Model, Thread, ThreadItem} from "./app-server/v2"; +import type { + ApprovalsReviewer, + AskForApproval, + Model, + PermissionProfileSummary, + Thread, + ThreadItem, +} from "./app-server/v2"; export type SessionMetadata = { sessionId: string, @@ -11,6 +18,10 @@ export type SessionMetadata = { currentServiceTier?: ServiceTier | null, additionalDirectories: string[], skippedMcpServers?: string[], + activePermissionProfileId?: string | null, + approvalPolicy?: AskForApproval, + approvalsReviewer?: ApprovalsReviewer, + permissionProfiles?: PermissionProfileSummary[], } export type SessionMetadataWithThread = SessionMetadata & { diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 043cc54e4..c0fff2ad4 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -101,6 +101,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { "account/updated", // The auth check of session/new. The session open reuses this read, so it reads the account once. "account/read", + "permissionProfile/list", "thread/start", "model/list", "thread/started", @@ -609,6 +610,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(); @@ -1452,6 +1492,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 f66be6f0c..8294ae78f 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -6,7 +6,7 @@ import { MODEL_CONFIG_ID, REASONING_EFFORT_CONFIG_ID, } from "../../ModelConfigOption"; -import type {Model, ReasoningEffortOption, Turn} from "../../app-server/v2"; +import type {Model, PermissionProfileSummary, ReasoningEffortOption, Turn} from "../../app-server/v2"; import {LEGACY_SET_SESSION_MODEL_METHOD} from "../../AcpExtensions"; import { COLLABORATION_MODE_CONFIG_ID, @@ -40,9 +40,23 @@ function buildModels(): {fast: Model; slow: Model} { async function createSession( currentModelId: string, availableModels: Array, - clientCapabilities?: acp.ClientCapabilities, - additionalDirectories: string[] = [], + options: { + clientCapabilities?: acp.ClientCapabilities; + additionalDirectories?: string[]; + permissionProfiles?: PermissionProfileSummary[]; + activePermissionProfileId?: string | null; + approvalPolicy?: "on-request" | "never"; + approvalsReviewer?: "user" | "auto_review"; + } = {}, ) { + const { + clientCapabilities, + additionalDirectories = [], + permissionProfiles = [], + activePermissionProfileId = null, + approvalPolicy = "on-request", + approvalsReviewer = "user", + } = options; const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); @@ -55,6 +69,10 @@ async function createSession( models: availableModels, collaborationMode: "default", additionalDirectories, + activePermissionProfileId, + approvalPolicy, + approvalsReviewer, + permissionProfiles, }); if (clientCapabilities) { @@ -174,7 +192,9 @@ describe("Session config options", () => { it("advertises the default model and its effort as recommended values after negotiation", async () => { const {fast, slow} = buildModels(); const {response} = await createSession("slow-model[medium]", [fast, slow], { - _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + clientCapabilities: { + _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + }, }); expect(response.configOptions?.find(option => option.id === MODEL_CONFIG_ID)).toMatchObject({ @@ -190,7 +210,9 @@ describe("Session config options", () => { it("updates the recommended effort when the selected model changes", async () => { const {fast, slow} = buildModels(); const {codexAcpAgent} = await createSession("fast-model[medium]", [fast, slow], { - _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + clientCapabilities: { + _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + }, }); const response = await codexAcpAgent.setSessionConfigOption({ @@ -213,7 +235,9 @@ describe("Session config options", () => { const {fast, slow} = buildModels(); fast.isDefault = false; const {response} = await createSession("slow-model[medium]", [fast, slow], { - _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + clientCapabilities: { + _meta: {jetbrains: {air: {version: 1, capabilities: ["recommendedValue"]}}}, + }, }); expect(response.configOptions?.find(option => option.id === MODEL_CONFIG_ID)?._meta).toBeUndefined(); @@ -223,6 +247,63 @@ describe("Session config options", () => { ); }); + 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, + activePermissionProfileId: "team-default", + approvalPolicy: "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.WorkspaceWrite.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]); @@ -267,9 +348,12 @@ describe("Session config options", () => { vi.stubEnv("INITIAL_AGENT_MODE", initialMode); const {fast} = buildModels(); const {fixture, codexAcpAgent, response} = await createSession("fast-model[medium]", [fast], { - fs: {readTextFile: false, writeTextFile: false}, - terminal: false, - }, ["/test/extra"]); + clientCapabilities: { + fs: {readTextFile: false, writeTextFile: false}, + terminal: false, + }, + additionalDirectories: ["/test/extra"], + }); expect(response.modes?.currentModeId).toBe(initialMode); if (selection === "session/set_mode") { @@ -308,6 +392,28 @@ describe("Session config options", () => { await expect(JSON.stringify(policies, null, 2) + "\n").toMatchFileSnapshot(`data/${modeId}-mode-policy.json`); }); + it("changes to a custom permission profile via setSessionConfigOption", async () => { + const {fast} = buildModels(); + const {codexAcpAgent} = await createSession("fast-model[medium]", [fast], { + permissionProfiles: [{ + 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 df6bcb74d..d1b494ed6 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -429,6 +429,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,