Skip to content
Merged
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
9 changes: 9 additions & 0 deletions readme-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ 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.

With external profiles, Full access accepts native tool approvals once for HTTP
MCP servers injected by ACP during the current prompt; changing mode revokes this.
All granular approval categories, including server-origin elicitations, stay
disabled. Other modes retain their normal approval options. Native MCP settings,
explicit per-tool approval rules, and stdio servers receive no automatic approval.
Child turns and already-loaded resumes are excluded because their effective
policy or configuration cannot be verified; cold resumes apply the supplied config.
Native hooks report `permission_mode: default` for this granular policy.

### Quick start

#### Develop on Windows?
Expand Down
60 changes: 47 additions & 13 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ import {
} from "./AgentFileChangeReport";
import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions";
import {forkSession as runForkSession} from "./SessionFork";
import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
import type {PreparedSessionConfig, SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
import {
permissionProfileForMode,
Expand Down Expand Up @@ -543,8 +543,10 @@ export class CodexAcpClient {
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
await this.restrictResumedHttpMcpServers(request.sessionId, prepared);
const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config: prepared.config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
Expand All @@ -561,6 +563,7 @@ export class CodexAcpClient {
modelProvider: response.modelProvider,
currentServiceTier: response.serviceTier as ServiceTier ?? null,
additionalDirectories,
fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers,
}
}

Expand All @@ -584,8 +587,10 @@ export class CodexAcpClient {
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []);
await this.restrictResumedHttpMcpServers(request.sessionId, prepared);
const response = await this.codexClient.threadResume({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers ?? []),
config: prepared.config,
cwd: request.cwd,
modelProvider: await this.getResumeModelProvider(),
threadId: request.sessionId,
Expand All @@ -607,6 +612,7 @@ export class CodexAcpClient {
currentServiceTier: response.serviceTier as ServiceTier ?? null,
thread: historyResponse.thread,
additionalDirectories,
fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers,
};
}

Expand All @@ -622,8 +628,9 @@ export class CodexAcpClient {
const initialAgentMode = AgentMode.getInitialAgentMode();
await this.refreshSkills(request.cwd, additionalDirectories);

const prepared = await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers);
const response = await this.codexClient.threadStart({
config: await this.createSessionConfig(request.cwd, additionalDirectories, request.mcpServers),
config: prepared.config,
modelProvider: this.getModelProvider(),
cwd: request.cwd,
...this.permissionProfileSelection(initialAgentMode, request.cwd, additionalDirectories),
Expand All @@ -642,6 +649,7 @@ export class CodexAcpClient {
modelProvider: response.modelProvider,
currentServiceTier: response.serviceTier as ServiceTier ?? null,
additionalDirectories,
fullAccessHttpMcpServers: prepared.fullAccessHttpMcpServers,
};
}

Expand All @@ -662,12 +670,29 @@ export class CodexAcpClient {
const config = this.permissionProfileConfig;
if (!config) return {};
return {
approvalPolicy: agentMode.approvalPolicy,
approvalPolicy: this.approvalPolicyForMode(agentMode),
permissions: permissionProfileForMode(config, agentMode.id),
runtimeWorkspaceRoots: sessionRoots(cwd, additionalDirectories),
};
}

isProtectedFullAccess(agentMode: AgentMode): boolean {
return this.permissionProfileConfig !== undefined && agentMode.kind === "full_access";
}

private approvalPolicyForMode(agentMode: AgentMode): AgentMode["approvalPolicy"] {
if (!this.isProtectedFullAccess(agentMode)) return agentMode.approvalPolicy;
// Codex 0.153.3 routes tool approvals separately; false blocks server-origin elicitations, not native tool approvals.
return {granular: {sandbox_approval: false, rules: false, skill_approval: false, request_permissions: false, mcp_elicitations: false}};
}

private async restrictResumedHttpMcpServers(sessionId: string, prepared: PreparedSessionConfig): Promise<void> {
if (prepared.fullAccessHttpMcpServers.length === 0) return;
const loaded = await this.codexClient.threadLoadedList({});
// A loaded thread may ignore resume config, so its requested transport is not proof of its actual transport.
if (loaded.data.includes(sessionId) || loaded.nextCursor !== null) prepared.fullAccessHttpMcpServers = [];
}

async deleteSession(sessionId: string): Promise<void> {
await this.codexClient.threadArchive({threadId: sessionId});
}
Expand Down Expand Up @@ -759,7 +784,7 @@ export class CodexAcpClient {
projectPath: string,
additionalDirectories: string[],
mcpServers: Array<McpServer>,
): Promise<JsonObject> {
): Promise<PreparedSessionConfig> {
const sessionRoots = [projectPath, ...additionalDirectories];
const activeProvider = this.gatewayConfig
? {
Expand All @@ -782,26 +807,35 @@ export class CodexAcpClient {
};
const configWithWorkspaceRoots = mergeSandboxWorkspaceWriteRoots(mergedConfig, additionalDirectories);
if (mcpServers.length === 0) {
return configWithWorkspaceRoots;
return {config: configWithWorkspaceRoots, fullAccessHttpMcpServers: []};
}

const requestedServers = mcpServers.map(mcp => ({
name: sanitizeMcpServerName(mcp.name),
server: mcp,
}));
let serversToConfigure = requestedServers;
const existingNames = shouldDeduplicateMcpConflicts() || this.permissionProfileConfig
? await this.getConfigMcpServerNames(projectPath)
: new Set<string>();
if (shouldDeduplicateMcpConflicts()) {
// Prevents Codex from deep-merging incompatible field types, such as url and stdio schemas.
const existingNames = await this.getConfigMcpServerNames(projectPath);
serversToConfigure = requestedServers.filter(mcp => !existingNames.has(mcp.name));
}
if (serversToConfigure.length === 0) {
return configWithWorkspaceRoots;
return {config: configWithWorkspaceRoots, fullAccessHttpMcpServers: []};
}

const configuredServers = Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)]));
const inheritedServers = isJsonObject(this.config["mcp_servers"]) ? this.config["mcp_servers"] : {};
return {
...configWithWorkspaceRoots,
"mcp_servers": Object.fromEntries(serversToConfigure.map(mcp => [mcp.name, this.createMcpSeverConfig(mcp.server)])),
config: {...configWithWorkspaceRoots, mcp_servers: configuredServers},
fullAccessHttpMcpServers: this.permissionProfileConfig
? Object.entries(configuredServers)
.filter(([name, server]) => typeof server["url"] === "string"
&& !existingNames.has(name) && !Object.hasOwn(inheritedServers, name))
.map(([name]) => name)
: [],
};
}

Expand Down Expand Up @@ -984,7 +1018,7 @@ export class CodexAcpClient {
return await this.codexClient.runTurn({
threadId: request.sessionId,
input: input,
approvalPolicy: agentMode.approvalPolicy,
approvalPolicy: this.approvalPolicyForMode(agentMode),
approvalsReviewer: agentMode.approvalsReviewer,
...sandboxSelection,
summary: disableSummary ? "none" : "auto",
Expand Down Expand Up @@ -1114,7 +1148,7 @@ export class CodexAcpClient {
if (!config) return;
await this.codexClient.threadSettingsUpdate({
threadId: sessionId,
approvalPolicy: agentMode.approvalPolicy,
approvalPolicy: this.approvalPolicyForMode(agentMode),
permissions: permissionProfileForMode(config, agentMode.id),
});
}
Expand Down
32 changes: 29 additions & 3 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ export interface SessionState {
supportedReasoningEfforts: Array<ReasoningEffortOption>,
supportedInputModalities: Array<InputModality>,
agentMode: AgentMode,
permissionModeRevision?: number;
fullAccessHttpMcpServers?: string[];
collaborationMode: ModeKind,
currentTurnId: string | null;
lastTokenUsage: TokenCount | null;
Expand Down Expand Up @@ -672,6 +674,7 @@ export class CodexAcpServer {
supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [],
supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"],
agentMode: AgentMode.getInitialAgentMode(),
fullAccessHttpMcpServers: sessionMetadata.fullAccessHttpMcpServers ?? [],
collaborationMode: sessionMetadata.collaborationMode,
currentTurnId: null,
lastTokenUsage: null,
Expand Down Expand Up @@ -1055,12 +1058,13 @@ export class CodexAcpServer {
for (const session of this.sessions.values()) {
session.asyncTasks.setAppServer(replacement.appServerClient);
try {
await replacement.resumeSession({
const metadata = await replacement.resumeSession({
sessionId: session.sessionId,
cwd: session.cwd,
additionalDirectories: session.additionalDirectories,
mcpServers: session.mcpServers ?? [],
});
session.fullAccessHttpMcpServers = metadata.fullAccessHttpMcpServers ?? [];
session.authProvider = replacement.getModelProvider();
session.asyncTasks.refresh();
logger.log("Resumed session after provider restart", {sessionId: session.sessionId});
Expand Down Expand Up @@ -1388,6 +1392,7 @@ export class CodexAcpServer {
if (!newMode) {
throw RequestError.invalidParams();
}
sessionState.permissionModeRevision = (sessionState.permissionModeRevision ?? 0) + 1;
await this.codexAcpClient.setAgentMode(
sessionState.sessionId,
newMode,
Expand Down Expand Up @@ -1933,6 +1938,7 @@ export class CodexAcpServer {
supportedReasoningEfforts: currentModel?.supportedReasoningEfforts ?? [],
supportedInputModalities: currentModel?.inputModalities ?? ["text", "image"],
agentMode: AgentMode.getInitialAgentMode(),
fullAccessHttpMcpServers: sessionMetadata.fullAccessHttpMcpServers ?? [],
collaborationMode: sessionMetadata.collaborationMode,
currentTurnId: null,
lastTokenUsage: null,
Expand Down Expand Up @@ -2807,16 +2813,30 @@ export class CodexAcpServer {
eventHandler = promptEventHandler;
const permissionLifecycle = this.permissionLifecycleContext(sessionState);
const permissionContext = permissionLifecycle.beginPrompt();
let promptAgentMode: AgentMode | undefined;
let permissionModeRevision: number | undefined;
const httpServers = new Set(sessionState.fullAccessHttpMcpServers);
const noHttpServers = new Set<string>();
let mcpApprovalTurnId: string | null = null;
const isProtectedFullAccess = () => this.codexAcpClient.isProtectedFullAccess(promptAgentMode ?? sessionState.agentMode);
const approvalHandler = new CodexApprovalHandler(
this.connection,
permissionContext,
activePrompt.signal,
isProtectedFullAccess,
);
const elicitationHandler = new CodexElicitationHandler(
this.connection,
permissionContext,
this.clientCapabilities,
activePrompt.signal,
request => {
if (!isProtectedFullAccess()) return undefined;
return mcpApprovalTurnId !== null && request.turnId === mcpApprovalTurnId
&& sessionState.agentMode.kind === "full_access"
&& sessionState.permissionModeRevision === permissionModeRevision
? httpServers : noHttpServers;
},
);
const observeInteraction = async (event: ServerNotification): Promise<void> => {
permissionContext.handleNotification(event);
Expand Down Expand Up @@ -2954,6 +2974,8 @@ export class CodexAcpServer {
throw RequestError.invalidRequest("The current model does not support image input");
}
const agentMode = sessionState.agentMode;
promptAgentMode = agentMode;
permissionModeRevision = sessionState.permissionModeRevision;
const serviceTier = resolveFastServiceTier(
sessionState.fastModeEnabled,
sessionState.currentModelSupportsFast,
Expand All @@ -2970,6 +2992,7 @@ export class CodexAcpServer {
sessionState.cwd,
sessionState.additionalDirectories,
(turnId) => {
mcpApprovalTurnId = turnId;
const turn = {threadId: params.sessionId, turnId};
activePrompt.currentTurn = turn;
if (this.promptShouldStop(params.sessionId, activePrompt)) {
Expand All @@ -2980,7 +3003,8 @@ export class CodexAcpServer {
pendingTurnStart?.resolve(turnId);
onTurnStarted?.();
},
() => this.promptShouldStop(params.sessionId, activePrompt),
() => this.promptShouldStop(params.sessionId, activePrompt)
|| sessionState.permissionModeRevision !== permissionModeRevision,
));
void sendPromptPromise.catch((err) => {
if (this.activePrompts.get(params.sessionId) !== activePrompt) {
Expand Down Expand Up @@ -3071,6 +3095,7 @@ export class CodexAcpServer {
sessionState.cwd,
sessionState.additionalDirectories,
(turnId) => {
mcpApprovalTurnId = turnId;
const turn = {threadId: params.sessionId, turnId};
activePrompt.currentTurn = turn;
if (this.promptShouldStop(params.sessionId, activePrompt)) {
Expand All @@ -3083,7 +3108,8 @@ export class CodexAcpServer {
recoverableSessionFailure = sessionState.sessionFailure;
promptNotificationsActive = true;
},
() => this.promptShouldStop(params.sessionId, activePrompt),
() => this.promptShouldStop(params.sessionId, activePrompt)
|| sessionState.permissionModeRevision !== permissionModeRevision,
),
);
void implementationPromise.catch((err) => {
Expand Down
Loading