Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<profile-id>`.
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.

Expand Down
2 changes: 1 addition & 1 deletion readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<profile-id>`.
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.

Expand Down
73 changes: 57 additions & 16 deletions src/AgentMode.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -64,44 +75,74 @@ 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",
description: "Approval and sandboxing preset for the session",
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,
})),
};
}

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;
}
}
43 changes: 42 additions & 1 deletion src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ import {sanitizeMcpServerName} from "./McpServerName";
import type {
AccountLoginCompletedNotification,
AccountUpdatedNotification,
AskForApproval,
GetAccountResponse,
ListMcpServerStatusResponse,
Model,
PermissionProfileSummary,
ReviewTarget,
SkillsListParams,
SkillsListResponse,
Expand Down Expand Up @@ -389,6 +391,7 @@ export class CodexAcpClient {
async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
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 ?? []),
Expand All @@ -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<SessionMetadataWithThread> {
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 ?? []),
Expand All @@ -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<SessionMetadata> {
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),
Expand All @@ -462,6 +473,9 @@ export class CodexAcpClient {
modelProvider: response.modelProvider,
currentServiceTier: response.serviceTier as ServiceTier ?? null,
additionalDirectories,
activePermissionProfileId: readActivePermissionProfileId(response),
approvalPolicy: response.approvalPolicy,
permissionProfiles,
};
}

Expand Down Expand Up @@ -607,6 +621,17 @@ export class CodexAcpClient {
return new Set(configuredMcpServers.flatMap(server => Object.keys(server)));
}

private async fetchPermissionProfiles(projectPath: string): Promise<PermissionProfileSummary[]> {
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;
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
29 changes: 23 additions & 6 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface SessionState {
supportedReasoningEfforts: Array<ReasoningEffortOption>,
supportedInputModalities: Array<InputModality>,
agentMode: AgentMode,
availableAgentModes: AgentMode[],
collaborationMode: ModeKind,
currentTurnId: string | null;
lastTokenUsage: TokenCount | null;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
}
Expand Down Expand Up @@ -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();
}
Expand Down Expand Up @@ -1267,7 +1276,7 @@ export class CodexAcpServer {
private createSessionConfigOptions(sessionState: SessionState): Array<acp.SessionConfigOption> {
const currentModelId = ModelId.fromString(sessionState.currentModelId);
const configOptions = [
sessionState.agentMode.toConfigOption(),
sessionState.agentMode.toConfigOption(sessionState.availableAgentModes),
createCollaborationModeConfigOption(sessionState.collaborationMode),
createModelConfigOption(sessionState.availableModels, currentModelId.model),
];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading