From 72670559cfde1ff768643456c8c211ae4d8737b4 Mon Sep 17 00:00:00 2001 From: Jeff Otano Date: Thu, 3 Sep 2026 16:55:48 -0600 Subject: [PATCH 1/3] fix(server): codex threads honor managed sandbox and approval requirements Codex under an MDM or requirements.toml policy rejects thread/start when the requested sandbox or approval policy is outside the allowed set: invalid thread settings override: invalid value for `sandbox_mode`: `DangerFullAccess` is not in the allowed set [ReadOnly, WorkspaceWrite] (set by MDM com.openai.codex:requirements_toml_base64) T3's full-access runtime mode always asked for danger-full-access and approval never, so every Codex thread failed on a managed machine. The session runtime now reads configRequirements/read once after initialize and lowers the requested sandbox and approval policy to the most permissive value the policy allows, on thread/start, thread/resume, and every turn/start. A setting is never raised above what the runtime mode asked for. When Codex does not support the method or reports no requirements, behavior is unchanged. Downgrades are logged and appended to the session/ready reason so the client can show them. Co-Authored-By: Claude Fable 5.1 --- .../Layers/CodexSessionRuntime.test.ts | 201 ++++++++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 173 +++++++++++++-- 2 files changed, 356 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6a6cec5b1e61..4b9f6779015f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -17,11 +17,14 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + describeCodexThreadSettingDowngrades, describeMcpElicitation, hasConfiguredMcpServer, isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + readCodexManagedRequirements, + resolveCodexThreadConfig, toMcpElicitationResponse, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); @@ -851,3 +854,201 @@ describe("openCodexThread", () => { }), ); }); + +describe("resolveCodexThreadConfig", () => { + it("keeps the requested settings when Codex reports no requirements", () => { + const resolved = resolveCodexThreadConfig("full-access", undefined); + + NodeAssert.deepStrictEqual(resolved.config, { + approvalPolicy: "never", + sandbox: "danger-full-access", + approvalsReviewer: "user", + }); + NodeAssert.deepStrictEqual(resolved.downgrades, []); + }); + + it("lowers full access to workspace-write when the policy forbids danger-full-access", () => { + const resolved = resolveCodexThreadConfig("full-access", { + allowedSandboxModes: ["read-only", "workspace-write"], + }); + + NodeAssert.equal(resolved.config.sandbox, "workspace-write"); + NodeAssert.equal(resolved.config.approvalPolicy, "never"); + NodeAssert.deepStrictEqual(resolved.downgrades, [ + { setting: "sandbox", requested: "danger-full-access", applied: "workspace-write" }, + ]); + }); + + it("lowers the approval policy to the most permissive allowed value", () => { + const resolved = resolveCodexThreadConfig("full-access", { + allowedApprovalPolicies: ["untrusted", "on-request"], + }); + + NodeAssert.equal(resolved.config.approvalPolicy, "on-request"); + NodeAssert.equal(resolved.config.sandbox, "danger-full-access"); + NodeAssert.deepStrictEqual(resolved.downgrades, [ + { setting: "approvalPolicy", requested: "never", applied: "on-request" }, + ]); + }); + + it("leaves settings alone when they are already allowed or the lists are null", () => { + NodeAssert.deepStrictEqual( + resolveCodexThreadConfig("auto", { + allowedSandboxModes: ["workspace-write"], + allowedApprovalPolicies: ["on-request"], + }).downgrades, + [], + ); + NodeAssert.deepStrictEqual( + resolveCodexThreadConfig("full-access", { + allowedSandboxModes: null, + allowedApprovalPolicies: null, + }).downgrades, + [], + ); + }); + + it("never raises a setting above what was requested", () => { + const resolved = resolveCodexThreadConfig("approval-required", { + allowedSandboxModes: ["workspace-write"], + }); + + NodeAssert.equal(resolved.config.sandbox, "read-only"); + NodeAssert.deepStrictEqual(resolved.downgrades, []); + }); + + it("describes downgrades in one sentence and nothing when there are none", () => { + NodeAssert.equal(describeCodexThreadSettingDowngrades([]), undefined); + NodeAssert.equal( + describeCodexThreadSettingDowngrades([ + { setting: "sandbox", requested: "danger-full-access", applied: "workspace-write" }, + ]), + "Managed Codex policy lowered thread settings: sandbox danger-full-access -> workspace-write.", + ); + }); +}); + +describe("buildTurnStartParams under managed requirements", () => { + it.effect("sends the lowered sandbox policy on every turn", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "hello", + requirements: { allowedSandboxModes: ["workspace-write"] }, + }); + + NodeAssert.deepStrictEqual(params.sandboxPolicy, { type: "workspaceWrite" }); + NodeAssert.equal(params.approvalPolicy, "never"); + }), + ); + + it.effect("keeps danger-full-access when no requirements are supplied", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "hello", + }); + + NodeAssert.deepStrictEqual(params.sandboxPolicy, { type: "dangerFullAccess" }); + }), + ); +}); + +describe("openCodexThread under managed requirements", () => { + it.effect("starts the thread with the lowered sandbox instead of failing", () => + Effect.gen(function* () { + const payloads: Array = []; + const client = { + request: ( + _method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + payloads.push(payload); + return Effect.succeed( + makeThreadOpenResponse("thread-1") as CodexRpc.ClientRequestResponsesByMethod[M], + ); + }, + }; + + yield* openCodexThread({ + client, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: undefined, + serviceTier: undefined, + resumeThreadId: undefined, + requirements: { allowedSandboxModes: ["read-only", "workspace-write"] }, + }); + + NodeAssert.equal(payloads.length, 1); + NodeAssert.equal( + (payloads[0] as EffectCodexSchema.V2ThreadStartParams).sandbox, + "workspace-write", + ); + NodeAssert.equal( + (payloads[0] as EffectCodexSchema.V2ThreadStartParams).approvalPolicy, + "never", + ); + }), + ); +}); + +describe("readCodexManagedRequirements", () => { + const requirements = { allowedSandboxModes: ["read-only", "workspace-write"] as const }; + + it.effect("returns the requirements Codex reports", () => + Effect.gen(function* () { + const client = { + request: ( + _method: "configRequirements/read", + _payload: CodexRpc.ClientRequestParamsByMethod["configRequirements/read"], + ) => + Effect.succeed({ + requirements, + } as CodexRpc.ClientRequestResponsesByMethod["configRequirements/read"]), + }; + + const result = yield* readCodexManagedRequirements(client); + + NodeAssert.deepStrictEqual(result?.allowedSandboxModes, ["read-only", "workspace-write"]); + }), + ); + + it.effect("treats a null requirements payload as no requirements", () => + Effect.gen(function* () { + const client = { + request: ( + _method: "configRequirements/read", + _payload: CodexRpc.ClientRequestParamsByMethod["configRequirements/read"], + ) => + Effect.succeed({ + requirements: null, + } as CodexRpc.ClientRequestResponsesByMethod["configRequirements/read"]), + }; + + NodeAssert.equal(yield* readCodexManagedRequirements(client), undefined); + }), + ); + + it.effect("treats an unsupported method as no requirements", () => + Effect.gen(function* () { + const client = { + request: ( + _method: "configRequirements/read", + _payload: CodexRpc.ClientRequestParamsByMethod["configRequirements/read"], + ) => + Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32601, + errorMessage: "Method not found", + }), + ), + }; + + NodeAssert.equal(yield* readCodexManagedRequirements(client), undefined); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index d83489763f5c..908f3fbba0b7 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -490,13 +490,15 @@ function readResumeCursorThreadId( return isCodexResumeCursorSchema(resumeCursor) ? resumeCursor.threadId : undefined; } -function runtimeModeToThreadConfig(input: RuntimeMode): { +interface CodexThreadConfig { readonly approvalPolicy: EffectCodexSchema.V2ThreadStartParams__AskForApproval; readonly sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode; // Always explicit: omitting the field on resume keeps the thread's previous // reviewer, which would leave auto_review sticky after switching modes. readonly approvalsReviewer: EffectCodexSchema.V2ThreadStartParams__ApprovalsReviewer; -} { +} + +function runtimeModeToThreadConfig(input: RuntimeMode): CodexThreadConfig { switch (input) { case "approval-required": return { @@ -526,13 +528,122 @@ function runtimeModeToThreadConfig(input: RuntimeMode): { } } +export type CodexManagedRequirements = Pick< + EffectCodexSchema.V2ConfigRequirementsReadResponse__ConfigRequirements, + "allowedApprovalPolicies" | "allowedSandboxModes" +>; + +export interface CodexThreadSettingDowngrade { + readonly setting: "sandbox" | "approvalPolicy"; + readonly requested: string; + readonly applied: string; +} + +export interface CodexResolvedThreadConfig { + readonly config: CodexThreadConfig; + readonly downgrades: ReadonlyArray; +} + +const SANDBOX_MODES_MOST_PERMISSIVE_FIRST: ReadonlyArray = + ["danger-full-access", "workspace-write", "read-only"]; + +const APPROVAL_POLICIES_MOST_PERMISSIVE_FIRST: ReadonlyArray<"never" | "on-request" | "untrusted"> = + ["never", "on-request", "untrusted"]; + +function mostPermissiveAllowedAtOrBelow( + requested: T, + order: ReadonlyArray, + allowed: ReadonlyArray | null | undefined, +): T { + if (!allowed || allowed.includes(requested)) { + return requested; + } + const requestedIndex = order.indexOf(requested); + if (requestedIndex < 0) { + return requested; + } + return ( + order.slice(requestedIndex + 1).find((candidate) => allowed.includes(candidate)) ?? requested + ); +} + +// Codex rejects the whole thread when a setting falls outside its managed requirements. +export function resolveCodexThreadConfig( + runtimeMode: RuntimeMode, + requirements: CodexManagedRequirements | undefined, +): CodexResolvedThreadConfig { + const requested = runtimeModeToThreadConfig(runtimeMode); + if (!requirements) { + return { config: requested, downgrades: [] }; + } + const downgrades: Array = []; + + const sandbox = mostPermissiveAllowedAtOrBelow( + requested.sandbox, + SANDBOX_MODES_MOST_PERMISSIVE_FIRST, + requirements.allowedSandboxModes, + ); + if (sandbox !== requested.sandbox) { + downgrades.push({ setting: "sandbox", requested: requested.sandbox, applied: sandbox }); + } + + let approvalPolicy = requested.approvalPolicy; + if (typeof approvalPolicy === "string") { + const allowedApprovalPolicy = mostPermissiveAllowedAtOrBelow( + approvalPolicy, + APPROVAL_POLICIES_MOST_PERMISSIVE_FIRST, + requirements.allowedApprovalPolicies, + ); + if (allowedApprovalPolicy !== approvalPolicy) { + downgrades.push({ + setting: "approvalPolicy", + requested: approvalPolicy, + applied: allowedApprovalPolicy, + }); + approvalPolicy = allowedApprovalPolicy; + } + } + + return { config: { ...requested, sandbox, approvalPolicy }, downgrades }; +} + +export function describeCodexThreadSettingDowngrades( + downgrades: ReadonlyArray, +): string | undefined { + if (downgrades.length === 0) { + return undefined; + } + const changes = downgrades + .map((downgrade) => `${downgrade.setting} ${downgrade.requested} -> ${downgrade.applied}`) + .join(", "); + return `Managed Codex policy lowered thread settings: ${changes}.`; +} + +export const readCodexManagedRequirements = (client: { + readonly request: ( + method: "configRequirements/read", + payload: CodexRpc.ClientRequestParamsByMethod["configRequirements/read"], + ) => Effect.Effect< + CodexRpc.ClientRequestResponsesByMethod["configRequirements/read"], + CodexErrors.CodexAppServerError + >; +}): Effect.Effect => + client.request("configRequirements/read", undefined).pipe( + Effect.map((response) => response.requirements ?? undefined), + Effect.catch((cause) => + Effect.logDebug("Codex App Server did not report config requirements.", { cause }).pipe( + Effect.as(undefined), + ), + ), + ); + function buildThreadStartParams(input: { readonly cwd: string; - readonly runtimeMode: RuntimeMode; + readonly config: CodexThreadConfig; readonly model: string | undefined; readonly serviceTier: CodexServiceTier | undefined; }): EffectCodexSchema.V2ThreadStartParams { - const config = runtimeModeToThreadConfig(input.runtimeMode); + const config = input.config; return { cwd: input.cwd, approvalPolicy: config.approvalPolicy, @@ -543,21 +654,19 @@ function buildThreadStartParams(input: { }; } -function runtimeModeToTurnSandboxPolicy( - input: RuntimeMode, +function sandboxModeToTurnSandboxPolicy( + sandbox: EffectCodexSchema.V2ThreadStartParams__SandboxMode, ): EffectCodexSchema.V2TurnStartParams__SandboxPolicy { - switch (input) { - case "approval-required": + switch (sandbox) { + case "read-only": return { type: "readOnly", }; - case "auto-accept-edits": - case "auto": + case "workspace-write": return { type: "workspaceWrite", }; - case "full-access": - default: + case "danger-full-access": return { type: "dangerFullAccess", }; @@ -603,6 +712,7 @@ export function buildTurnStartParams(input: { readonly interactionMode?: ProviderInteractionMode; /** Defaults to true so callers that predate the agent-access gate are unchanged. */ readonly browserToolsAvailable?: boolean; + readonly requirements?: CodexManagedRequirements; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, CodexErrors.CodexAppServerProtocolParseError @@ -618,7 +728,7 @@ export function buildTurnStartParams(input: { turnInput.push(attachment); } - const config = runtimeModeToThreadConfig(input.runtimeMode); + const { config } = resolveCodexThreadConfig(input.runtimeMode, input.requirements); const collaborationMode = buildCodexCollaborationMode({ ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), ...(input.model ? { model: input.model } : {}), @@ -631,7 +741,7 @@ export function buildTurnStartParams(input: { input: turnInput, approvalPolicy: config.approvalPolicy, approvalsReviewer: config.approvalsReviewer, - sandboxPolicy: runtimeModeToTurnSandboxPolicy(input.runtimeMode), + sandboxPolicy: sandboxModeToTurnSandboxPolicy(config.sandbox), ...(input.model ? { model: input.model } : {}), ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), ...(input.effort ? { effort: input.effort } : {}), @@ -696,20 +806,32 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly requirements?: CodexManagedRequirements; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; + const resolved = resolveCodexThreadConfig(input.runtimeMode, input.requirements); const startParams = buildThreadStartParams({ cwd: input.cwd, - runtimeMode: input.runtimeMode, + config: resolved.config, model: input.requestedModel, serviceTier: input.serviceTier, }); + const warnAboutDowngrades = + resolved.downgrades.length === 0 + ? Effect.void + : Effect.logWarning("codex managed requirements lowered thread settings", { + threadId: input.threadId, + requestedRuntimeMode: input.runtimeMode, + downgrades: resolved.downgrades, + }); if (resumeThreadId === undefined) { - return input.client.request("thread/start", startParams); + return warnAboutDowngrades.pipe( + Effect.andThen(input.client.request("thread/start", startParams)), + ); } - return input.client + const resumed = input.client .request("thread/resume", { threadId: resumeThreadId, ...startParams, @@ -725,6 +847,7 @@ export const openCodexThread = (input: { }).pipe(Effect.andThen(input.client.request("thread/start", startParams))), ), ); + return warnAboutDowngrades.pipe(Effect.andThen(resumed)); }; function readNotificationThreadId(notification: CodexServerNotification): string | undefined { @@ -1169,6 +1292,7 @@ export const makeCodexSessionRuntime = ( const collabChildMetadataRef = yield* Ref.make(new Map()); /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); + const managedRequirementsRef = yield* Ref.make(undefined); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); const closedRef = yield* Ref.make(false); @@ -2234,6 +2358,8 @@ export const makeCodexSessionRuntime = ( yield* client.notify("initialized", undefined); const requestedModel = normalizeCodexModelSlug(options.model); + const managedRequirements = yield* readCodexManagedRequirements(client); + yield* Ref.set(managedRequirementsRef, managedRequirements); const opened = yield* openCodexThread({ client, @@ -2243,6 +2369,7 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + ...(managedRequirements ? { requirements: managedRequirements } : {}), }); const providerThreadId = opened.thread.id; @@ -2255,7 +2382,15 @@ export const makeCodexSessionRuntime = ( updatedAt: yield* nowIso, } satisfies ProviderSession; yield* Ref.set(sessionRef, session); - yield* emitSessionEvent("session/ready", "Codex App Server session ready."); + const downgradeNotice = describeCodexThreadSettingDowngrades( + resolveCodexThreadConfig(options.runtimeMode, managedRequirements).downgrades, + ); + yield* emitSessionEvent( + "session/ready", + downgradeNotice + ? `Codex App Server session ready. ${downgradeNotice}` + : "Codex App Server session ready.", + ); return session; }); @@ -2308,9 +2443,11 @@ export const makeCodexSessionRuntime = ( const normalizedModel = normalizeCodexModelSlug( input.model ?? (yield* Ref.get(sessionRef)).model, ); + const managedRequirements = yield* Ref.get(managedRequirementsRef); const params = yield* buildTurnStartParams({ threadId: providerThreadId, runtimeMode: options.runtimeMode, + ...(managedRequirements ? { requirements: managedRequirements } : {}), ...(input.input ? { prompt: input.input } : {}), ...(input.attachments ? { attachments: input.attachments } : {}), ...(normalizedModel ? { model: normalizedModel } : {}), From 690784e97575403bc6cef66a121cdd1072ae8495 Mon Sep 17 00:00:00 2001 From: Jeff Otano Date: Thu, 3 Sep 2026 17:25:17 -0600 Subject: [PATCH 2/3] fix(server): honor granular approval policies and bound the requirements read Review follow-ups on the managed-requirements clamp. A policy whose allowedApprovalPolicies holds only granular entries has no string the runtime mode maps to, so the resolver now adopts the first allowed entry instead of sending a string Codex will reject. An allowed string at or below the requested policy still wins over a granular one. configRequirements/read is optional, so it now times out after five seconds and is treated as "no requirements", keeping session start from hanging on an app-server that never answers the method. Co-Authored-By: Claude Fable 5.1 --- .../Layers/CodexSessionRuntime.test.ts | 43 ++++++++++++ .../provider/Layers/CodexSessionRuntime.ts | 65 ++++++++++++++----- 2 files changed, 92 insertions(+), 16 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 4b9f6779015f..59adc9b9f977 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -2,7 +2,9 @@ import * as NodeAssert from "node:assert/strict"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; import { describe } from "vite-plus/test"; import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; @@ -917,6 +919,31 @@ describe("resolveCodexThreadConfig", () => { NodeAssert.deepStrictEqual(resolved.downgrades, []); }); + it("adopts the granular approval policy when that is all the policy allows", () => { + const granular = { + granular: { mcp_elicitations: true, rules: true, sandbox_approval: true }, + }; + const resolved = resolveCodexThreadConfig("full-access", { + allowedApprovalPolicies: [granular], + }); + + NodeAssert.deepStrictEqual(resolved.config.approvalPolicy, granular); + NodeAssert.deepStrictEqual(resolved.downgrades, [ + { setting: "approvalPolicy", requested: "never", applied: "granular" }, + ]); + }); + + it("prefers an allowed string policy over a granular one", () => { + const resolved = resolveCodexThreadConfig("full-access", { + allowedApprovalPolicies: [ + { granular: { mcp_elicitations: true, rules: true, sandbox_approval: true } }, + "on-request", + ], + }); + + NodeAssert.equal(resolved.config.approvalPolicy, "on-request"); + }); + it("describes downgrades in one sentence and nothing when there are none", () => { NodeAssert.equal(describeCodexThreadSettingDowngrades([]), undefined); NodeAssert.equal( @@ -1033,6 +1060,22 @@ describe("readCodexManagedRequirements", () => { }), ); + it.effect("gives up on a request that never answers", () => + Effect.gen(function* () { + const client = { + request: ( + _method: "configRequirements/read", + _payload: CodexRpc.ClientRequestParamsByMethod["configRequirements/read"], + ) => Effect.never, + }; + + const reading = yield* Effect.forkChild(readCodexManagedRequirements(client)); + yield* TestClock.adjust("6 seconds"); + + NodeAssert.equal(yield* Fiber.join(reading), undefined); + }), + ); + it.effect("treats an unsupported method as no requirements", () => Effect.gen(function* () { const client = { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 908f3fbba0b7..e99d0e05ea82 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -25,6 +25,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -587,26 +588,50 @@ export function resolveCodexThreadConfig( downgrades.push({ setting: "sandbox", requested: requested.sandbox, applied: sandbox }); } - let approvalPolicy = requested.approvalPolicy; - if (typeof approvalPolicy === "string") { - const allowedApprovalPolicy = mostPermissiveAllowedAtOrBelow( - approvalPolicy, - APPROVAL_POLICIES_MOST_PERMISSIVE_FIRST, - requirements.allowedApprovalPolicies, - ); - if (allowedApprovalPolicy !== approvalPolicy) { - downgrades.push({ - setting: "approvalPolicy", - requested: approvalPolicy, - applied: allowedApprovalPolicy, - }); - approvalPolicy = allowedApprovalPolicy; - } + const approvalPolicy = resolveAllowedApprovalPolicy( + requested.approvalPolicy, + requirements.allowedApprovalPolicies, + ); + if (approvalPolicy !== requested.approvalPolicy) { + downgrades.push({ + setting: "approvalPolicy", + requested: describeApprovalPolicy(requested.approvalPolicy), + applied: describeApprovalPolicy(approvalPolicy), + }); } return { config: { ...requested, sandbox, approvalPolicy }, downgrades }; } +type CodexApprovalPolicy = EffectCodexSchema.V2ThreadStartParams__AskForApproval; + +function describeApprovalPolicy(policy: CodexApprovalPolicy): string { + return typeof policy === "string" ? policy : "granular"; +} + +// A policy that allows only granular entries has no string the runtime mode maps to, +// so the first allowed entry is the only value Codex will accept. +function resolveAllowedApprovalPolicy( + requested: CodexApprovalPolicy, + allowed: ReadonlyArray | null | undefined, +): CodexApprovalPolicy { + if (!allowed || typeof requested !== "string") { + return requested; + } + const lowered = mostPermissiveAllowedAtOrBelow( + requested, + APPROVAL_POLICIES_MOST_PERMISSIVE_FIRST, + allowed, + ); + if (allowed.includes(lowered)) { + return lowered; + } + if (allowed.some((policy) => typeof policy === "string")) { + return requested; + } + return allowed[0] ?? requested; +} + export function describeCodexThreadSettingDowngrades( downgrades: ReadonlyArray, ): string | undefined { @@ -619,6 +644,8 @@ export function describeCodexThreadSettingDowngrades( return `Managed Codex policy lowered thread settings: ${changes}.`; } +const CODEX_REQUIREMENTS_READ_TIMEOUT_MS = 5_000; + export const readCodexManagedRequirements = (client: { readonly request: ( method: "configRequirements/read", @@ -629,7 +656,13 @@ export const readCodexManagedRequirements = (client: { >; }): Effect.Effect => client.request("configRequirements/read", undefined).pipe( - Effect.map((response) => response.requirements ?? undefined), + Effect.timeoutOption(CODEX_REQUIREMENTS_READ_TIMEOUT_MS), + Effect.map( + Option.match({ + onNone: () => undefined, + onSome: (response) => response.requirements ?? undefined, + }), + ), Effect.catch((cause) => Effect.logDebug("Codex App Server did not report config requirements.", { cause }).pipe( Effect.as(undefined), From b071dd95699b597e42d8a30967cc39e59b90756e Mon Sep 17 00:00:00 2001 From: Jeff Otano Date: Thu, 3 Sep 2026 17:49:38 -0600 Subject: [PATCH 3/3] fix(server): fall back to a granular approval policy when no allowed string fits When the allowed approval policies mix a granular entry with strings that are all more permissive than the requested mode, the resolver kept the requested string because some string existed, and Codex rejected it. It now adopts the first granular entry whenever no allowed string is at or below the request, and keeps the request only when nothing usable exists. Co-Authored-By: Claude Fable 5.1 --- .../provider/Layers/CodexSessionRuntime.test.ts | 14 ++++++++++++++ .../src/provider/Layers/CodexSessionRuntime.ts | 9 +++------ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 59adc9b9f977..9ca70f14b7f0 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -933,6 +933,20 @@ describe("resolveCodexThreadConfig", () => { ]); }); + it("falls back to granular when every allowed string is more permissive than requested", () => { + const granular = { + granular: { mcp_elicitations: true, rules: true, sandbox_approval: true }, + }; + const resolved = resolveCodexThreadConfig("auto", { + allowedApprovalPolicies: ["never", granular], + }); + + NodeAssert.deepStrictEqual(resolved.config.approvalPolicy, granular); + NodeAssert.deepStrictEqual(resolved.downgrades, [ + { setting: "approvalPolicy", requested: "on-request", applied: "granular" }, + ]); + }); + it("prefers an allowed string policy over a granular one", () => { const resolved = resolveCodexThreadConfig("full-access", { allowedApprovalPolicies: [ diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index e99d0e05ea82..7876ab703496 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -609,8 +609,8 @@ function describeApprovalPolicy(policy: CodexApprovalPolicy): string { return typeof policy === "string" ? policy : "granular"; } -// A policy that allows only granular entries has no string the runtime mode maps to, -// so the first allowed entry is the only value Codex will accept. +// When no allowed string is at or below the request, a granular entry is the only +// value Codex will accept without escalating past what the runtime mode asked for. function resolveAllowedApprovalPolicy( requested: CodexApprovalPolicy, allowed: ReadonlyArray | null | undefined, @@ -626,10 +626,7 @@ function resolveAllowedApprovalPolicy( if (allowed.includes(lowered)) { return lowered; } - if (allowed.some((policy) => typeof policy === "string")) { - return requested; - } - return allowed[0] ?? requested; + return allowed.find((policy) => typeof policy !== "string") ?? requested; } export function describeCodexThreadSettingDowngrades(