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
14 changes: 14 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp
- `NO_BROWSER` - hide browser-based ChatGPT auth when set.
- `APP_SERVER_LOGS` - directory for adapter logs.

### External permission profiles

A trusted runtime launcher may set `CODEX_ACP_PERMISSION_PROFILE_CONFIG` to a JSON
object containing `configOverrides` for the long-lived Codex app-server and a
`modeProfiles` mapping for the `read-only`, `agent`, and `agent-full-access` ACP
modes. The adapter treats profile definitions as opaque operator policy.

When configured, codex-acp selects the matching profile on thread start, resume,
and mode changes, preserves ACP additional workspace roots, and omits the legacy
per-turn `sandboxPolicy` that would otherwise override the selected profile. The
launch variable is removed from the Codex child environment after it is parsed.
Malformed or incomplete mappings fail startup instead of falling back to legacy
sandbox behavior.

### Quick start

#### Develop on Windows?
Expand Down
59 changes: 56 additions & 3 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ import packageJson from "../package.json";
import type {AuthenticationStatusResponse} from "./AcpExtensions";
import {createCodexCollaborationMode} from "./CollaborationModeConfig";
import type {ModeKind} from "./app-server/ModeKind";
import {
permissionProfileForMode,
type PermissionProfileConfig,
} from "./PermissionProfileConfig";

/**
* Well-known provider id for the client-configurable custom LLM gateway.
Expand All @@ -68,6 +72,7 @@ export class CodexAcpClient {
private readonly codexClient: CodexAppServerClient;
private readonly config: JsonObject;
private readonly modelProvider: string | null;
private readonly permissionProfileConfig: PermissionProfileConfig | undefined;
private gatewayConfig: GatewayConfig | null;
private pendingLoginCompleted: Promise<AccountLoginCompletedNotification> | null = null;
private pendingAccountUpdated: Promise<AccountUpdatedNotification> | null = null;
Expand All @@ -76,10 +81,16 @@ export class CodexAcpClient {
private configPath: string | null = null;


constructor(codexClient: CodexAppServerClient, codexConfig?: JsonObject, modelProvider?: string) {
constructor(
codexClient: CodexAppServerClient,
codexConfig?: JsonObject,
modelProvider?: string,
permissionProfileConfig?: PermissionProfileConfig,
) {
this.codexClient = codexClient;
this.config = codexConfig ?? {};
this.modelProvider = modelProvider ?? null;
this.permissionProfileConfig = permissionProfileConfig;
this.gatewayConfig = null;
}

Expand Down Expand Up @@ -328,13 +339,15 @@ export class CodexAcpClient {

async resumeSession(request: acp.ResumeSessionRequest, onSubscribed?: () => void): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
...this.permissionProfileSelection(initialAgentMode, request.cwd, additionalDirectories),
});
onSubscribed?.();
const codexModels = await this.fetchAvailableModels();
Expand All @@ -352,13 +365,15 @@ export class CodexAcpClient {

async loadSession(request: acp.LoadSessionRequest, onSubscribed?: () => void): Promise<SessionMetadataWithThread> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
...this.permissionProfileSelection(initialAgentMode, request.cwd, additionalDirectories),
});
onSubscribed?.();
const historyResponse = await this.codexClient.threadRead({
Expand All @@ -381,12 +396,14 @@ export class CodexAcpClient {

async newSession(request: acp.NewSessionRequest): Promise<SessionMetadata> {
const additionalDirectories = readAdditionalDirectories(request.cwd, request.additionalDirectories, request._meta);
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const response = await this.codexClient.threadStart({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
modelProvider: this.getModelProvider(),
cwd: request.cwd,
...this.permissionProfileSelection(initialAgentMode, request.cwd, additionalDirectories),
});

const codexModels = await this.fetchAvailableModels();
Expand All @@ -413,6 +430,20 @@ export class CodexAcpClient {
}
}

private permissionProfileSelection(
agentMode: AgentMode,
cwd: string,
additionalDirectories: string[],
): {approvalPolicy: AgentMode["approvalPolicy"]; permissions: string; runtimeWorkspaceRoots: string[]} | Record<string, never> {
const config = this.permissionProfileConfig;
if (!config) return {};
return {
approvalPolicy: agentMode.approvalPolicy,
permissions: permissionProfileForMode(config, agentMode.id),
runtimeWorkspaceRoots: sessionRoots(cwd, additionalDirectories),
};
}

async deleteSession(sessionId: string): Promise<void> {
await this.codexClient.threadArchive({threadId: sessionId});
}
Expand Down Expand Up @@ -489,7 +520,7 @@ export class CodexAcpClient {
private async createSessionConfig(
projectPath: string,
additionalDirectories: string[],
mcpServers: Array<McpServer>
mcpServers: Array<McpServer>,
): Promise<JsonObject> {
const sessionRoots = [projectPath, ...additionalDirectories];
const mergedConfig = {
Expand Down Expand Up @@ -700,18 +731,36 @@ export class CodexAcpClient {
if (shouldCancel?.()) {
return null;
}
const sandboxSelection = this.permissionProfileConfig ? {
runtimeWorkspaceRoots: sessionRoots(cwd, additionalDirectories),
} : {
sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories),
};
return await this.codexClient.runTurn({
threadId: request.sessionId,
input: input,
approvalPolicy: agentMode.approvalPolicy,
sandboxPolicy: addAdditionalDirectoriesToSandboxPolicy(agentMode.sandboxPolicy, additionalDirectories),
...sandboxSelection,
summary: disableSummary ? "none" : "auto",
effort: effort,
model: modelId.model,
serviceTier: serviceTier,
}, onTurnStarted);
}

async setAgentMode(
sessionId: string,
agentMode: AgentMode,
): Promise<void> {
const config = this.permissionProfileConfig;
if (!config) return;
await this.codexClient.threadSettingsUpdate({
threadId: sessionId,
approvalPolicy: agentMode.approvalPolicy,
permissions: permissionProfileForMode(config, agentMode.id),
});
}

async setCollaborationMode(sessionId: string, mode: ModeKind, currentModelId: string): Promise<void> {
await this.codexClient.threadSettingsUpdate({
threadId: sessionId,
Expand Down Expand Up @@ -1066,6 +1115,10 @@ function uniqueStrings(values: string[]): string[] {
return Array.from(new Set(values));
}

function sessionRoots(cwd: string, additionalDirectories: string[]): string[] {
return uniqueStrings([cwd, ...additionalDirectories]);
}

function arraysEqual(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false;
Expand Down
10 changes: 7 additions & 3 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,7 +734,7 @@ export class CodexAcpServer {
const sessionState = this.sessions.get(_params.sessionId);
if (!sessionState) throw new Error(`Session ${_params.sessionId} not found`);

this.applyModeChange(sessionState, _params.modeId);
await this.applyModeChange(sessionState, _params.modeId);
return {};
}

Expand All @@ -759,7 +759,7 @@ export class CodexAcpServer {
this.applyFastModeChange(sessionState, params);
break;
case MODE_CONFIG_ID:
this.applyModeChange(sessionState, this.stringConfigValue(params));
await this.applyModeChange(sessionState, this.stringConfigValue(params));
break;
case COLLABORATION_MODE_CONFIG_ID:
await this.applyCollaborationModeChange(sessionState, this.stringConfigValue(params));
Expand Down Expand Up @@ -794,11 +794,15 @@ export class CodexAcpServer {
return params.value;
}

private applyModeChange(sessionState: SessionState, value: string): void {
private async applyModeChange(sessionState: SessionState, value: string): Promise<void> {
const newMode = AgentMode.find(value);
if (!newMode) {
throw RequestError.invalidParams();
}
await this.codexAcpClient.setAgentMode(
sessionState.sessionId,
newMode,
);
sessionState.agentMode = newMode;
}

Expand Down
31 changes: 26 additions & 5 deletions src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
ServerNotification
} from "./app-server";
import type {
AskForApproval,
ConfigReadParams,
ConfigReadResponse,
GetAccountParams,
Expand Down Expand Up @@ -266,11 +267,11 @@ export class CodexAppServerClient {
return await this.sendRequest({ method: "initialize", params: params });
}

async turnStart(params: TurnStartParams): Promise<TurnStartResponse> {
async turnStart(params: ExperimentalTurnStartParams): Promise<TurnStartResponse> {
return await this.sendRequest({ method: "turn/start", params: params });
}

async runTurn(params: TurnStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
async runTurn(params: ExperimentalTurnStartParams, onTurnStarted?: (turnId: string) => void): Promise<TurnCompletedNotification> {
const capturedCompletions: Array<TurnCompletedNotification> = [];
const releaseCapture = this.captureTurnCompletions(params.threadId, (event) => {
capturedCompletions.push(event);
Expand Down Expand Up @@ -520,11 +521,11 @@ export class CodexAppServerClient {
this.staleTurnIds.set(threadId, threadStaleTurns);
}

async threadStart(params: ThreadStartParams): Promise<ThreadStartResponse> {
async threadStart(params: ExperimentalThreadStartParams): Promise<ThreadStartResponse> {
return await this.sendRequest({ method: "thread/start", params: params });
}

async threadResume(params: ThreadResumeParams): Promise<ThreadResumeResponse> {
async threadResume(params: ExperimentalThreadResumeParams): Promise<ThreadResumeResponse> {
return await this.sendRequest({ method: "thread/resume", params: params });
}

Expand Down Expand Up @@ -976,16 +977,36 @@ type DistributiveOmit<T, K extends keyof any> = T extends any

export interface ExperimentalThreadSettingsUpdateParams {
threadId: string;
collaborationMode: {
approvalPolicy?: AskForApproval;
collaborationMode?: {
mode: "default" | "plan";
settings: {
model: string;
reasoning_effort: string | null;
developer_instructions: string | null;
};
};
permissions?: string;
}

// Codex's generated public TypeScript schema omits fields gated by ExperimentalApi.
// This adapter opts into that API during initialize, so keep the small wire extension
// local until those fields are emitted by `codex app-server generate-ts`.
export type ExperimentalThreadStartParams = ThreadStartParams & {
permissions?: string;
runtimeWorkspaceRoots?: string[];
};

export type ExperimentalThreadResumeParams = ThreadResumeParams & {
permissions?: string;
runtimeWorkspaceRoots?: string[];
};

export type ExperimentalTurnStartParams = TurnStartParams & {
permissions?: string;
runtimeWorkspaceRoots?: string[];
};

type McpServerStartupSnapshot = {
status: McpServerStartupState;
error: string | null;
Expand Down
13 changes: 8 additions & 5 deletions src/CodexJsonRpcConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,20 @@ export interface CodexConnection {
readonly process: ChildProcessWithoutNullStreams;
}

export function startCodexConnection(codexPath?: string, env?: NodeJS.ProcessEnv): CodexConnection {
export function startCodexConnection(
codexPath?: string,
env?: NodeJS.ProcessEnv,
configOverrides: string[] = [],
): CodexConnection {
const spawnEnv = env ?? process.env;
const args = ["app-server", ...configOverrides.flatMap((override) => ["-c", override])];

let codex: ChildProcessWithoutNullStreams;
if (codexPath) {
codex = process.platform === 'win32'
? spawn(`"${codexPath}" app-server`, { shell: true, env: spawnEnv })
: spawn(codexPath, ['app-server'], { env: spawnEnv });
codex = spawn(codexPath, args, {env: spawnEnv, shell: process.platform === "win32"});
} else {
const bundledCodexPath = createRequire(import.meta.url).resolve("@openai/codex/bin/codex.js");
codex = spawn(process.execPath, [bundledCodexPath, 'app-server'], {env: spawnEnv});
codex = spawn(process.execPath, [bundledCodexPath, ...args], {env: spawnEnv});
}

attachLogs(codex);
Expand Down
41 changes: 41 additions & 0 deletions src/PermissionProfileConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {z} from "zod";

export const PERMISSION_PROFILE_CONFIG_ENV = "CODEX_ACP_PERMISSION_PROFILE_CONFIG";

const permissionProfileConfigSchema = z.object({
configOverrides: z.array(z.string().min(1)).min(1),
modeProfiles: z.object({
"read-only": z.string().min(1),
agent: z.string().min(1),
"agent-full-access": z.string().min(1),
}),
});

export type PermissionProfileConfig = z.infer<typeof permissionProfileConfigSchema>;

/**
* Read an operator-owned, launch-wide permission profile mapping. The adapter
* deliberately treats profile definitions as opaque Codex configuration: the
* trusted launcher owns policy construction, while codex-acp only selects the
* profile matching the active ACP mode.
*/
export function readPermissionProfileConfig(
env: NodeJS.ProcessEnv = process.env,
): PermissionProfileConfig | undefined {
const raw = env[PERMISSION_PROFILE_CONFIG_ENV];
if (!raw) return undefined;

let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`${PERMISSION_PROFILE_CONFIG_ENV} must contain a JSON object`);
}
return permissionProfileConfigSchema.parse(parsed);
}

export function permissionProfileForMode(config: PermissionProfileConfig, modeId: string): string {
const profile = config.modeProfiles[modeId as keyof PermissionProfileConfig["modeProfiles"]];
if (!profile) throw new Error(`No permission profile configured for ACP mode ${modeId}`);
return profile;
}
Loading