diff --git a/README.md b/README.md index f43ad986..81d7f7a1 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/recommended-config-values-extension.md) capability. - 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. @@ -67,7 +67,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 7040057f..737930cb 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -39,6 +39,7 @@ import type { McpServerOauthLoginParams, McpServerOauthLoginResponse, Model, + PermissionProfileSummary, ReviewTarget, SkillsListParams, SkillsListResponse, @@ -530,6 +531,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({ excludeTurns: true, @@ -549,6 +551,10 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, } } @@ -570,6 +576,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 response = await this.codexClient.threadResume({ excludeTurns: true, @@ -600,6 +607,10 @@ export class CodexAcpClient { currentServiceTier: response.serviceTier as ServiceTier ?? null, thread, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, }; } @@ -610,6 +621,7 @@ export class CodexAcpClient { 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), @@ -630,6 +642,10 @@ export class CodexAcpClient { modelProvider: response.modelProvider, currentServiceTier: response.serviceTier as ServiceTier ?? null, additionalDirectories, + activePermissionProfileId: readActivePermissionProfileId(response), + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + permissionProfiles, }; } @@ -793,6 +809,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; } @@ -950,12 +977,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, @@ -1325,6 +1358,13 @@ class AgentFileChangeReportBudget { export type JsonObject = { [key in string]?: JsonValue } +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 4ff0e1b4..cb70c425 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -160,6 +160,7 @@ export interface SessionState { supportedReasoningEfforts: Array, supportedInputModalities: Array, agentMode: AgentMode, + availableAgentModes: AgentMode[], collaborationMode: ModeKind, currentTurnId: string | null; lastTokenUsage: TokenCount | null; @@ -659,13 +660,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, @@ -718,7 +728,7 @@ export class CodexAcpServer { this.publishAsyncTasksAsync(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]; } @@ -1373,7 +1383,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(); } @@ -1734,7 +1744,7 @@ export class CodexAcpServer { ? sessionState.availableModels.find(model => model.isDefault)?.id : undefined; const configOptions = [ - sessionState.agentMode.toConfigOption(), + sessionState.agentMode.toConfigOption(sessionState.availableAgentModes), createCollaborationModeConfigOption(sessionState.collaborationMode), createModelConfigOption(sessionState.availableModels, currentModelId.model, recommendedModelId), ]; @@ -1916,13 +1926,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, @@ -1969,7 +1988,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 daa7e875..0c8033b0 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -28,6 +28,8 @@ import type { McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, + PermissionProfileListParams, + PermissionProfileListResponse, ReviewStartParams, ReviewStartResponse, SkillsExtraRootsSetParams, @@ -288,11 +290,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); @@ -762,6 +764,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 }); } @@ -1097,6 +1103,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 321ac5da..7453cbeb 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -426,12 +426,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 50562058..21069db7 100644 --- a/src/SessionMetadata.ts +++ b/src/SessionMetadata.ts @@ -1,6 +1,12 @@ import type {ModeKind} from "./app-server/ModeKind"; import type {ServiceTier} from "./app-server/ServiceTier"; -import type {Model, Thread} from "./app-server/v2"; +import type { + ApprovalsReviewer, + AskForApproval, + Model, + PermissionProfileSummary, + Thread, +} from "./app-server/v2"; export type SessionMetadata = { sessionId: string, @@ -10,6 +16,10 @@ export type SessionMetadata = { modelProvider?: string | null, currentServiceTier?: ServiceTier | null, additionalDirectories: 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 fe51f160..7f3467fa 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -96,6 +96,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { // Reads the connection auth identity for the `auth/status_update` push // when no session is open yet. "account/read", + "permissionProfile/list", "thread/start", "model/list", "thread/started", @@ -523,6 +524,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(); @@ -1105,6 +1145,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 fe1f5006..396396ca 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} 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, @@ -40,8 +40,21 @@ function buildModels(): {fast: Model; slow: Model} { async function createSession( currentModelId: string, availableModels: Array, - clientCapabilities?: acp.ClientCapabilities, + options: { + clientCapabilities?: acp.ClientCapabilities; + permissionProfiles?: PermissionProfileSummary[]; + activePermissionProfileId?: string | null; + approvalPolicy?: "on-request" | "never"; + approvalsReviewer?: "user" | "auto_review"; + } = {}, ) { + const { + clientCapabilities, + permissionProfiles = [], + activePermissionProfileId = null, + approvalPolicy = "on-request", + approvalsReviewer = "user", + } = options; const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); @@ -54,6 +67,10 @@ async function createSession( models: availableModels, collaborationMode: "default", additionalDirectories: [], + activePermissionProfileId, + approvalPolicy, + approvalsReviewer, + permissionProfiles, }); if (clientCapabilities) { @@ -157,7 +174,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({ @@ -173,7 +192,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({ @@ -196,7 +217,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(); @@ -206,6 +229,62 @@ 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.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]); @@ -235,6 +314,28 @@ 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], { + 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 f358664c..02ab9c2a 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -414,6 +414,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,