diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index 6a6cec5b1e61..9ca70f14b7f0 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"; @@ -17,11 +19,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 +856,256 @@ 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("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("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: [ + { 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( + 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("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 = { + 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..7876ab703496 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"; @@ -490,13 +491,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 +529,151 @@ 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 }); + } + + 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"; +} + +// 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, +): CodexApprovalPolicy { + if (!allowed || typeof requested !== "string") { + return requested; + } + const lowered = mostPermissiveAllowedAtOrBelow( + requested, + APPROVAL_POLICIES_MOST_PERMISSIVE_FIRST, + allowed, + ); + if (allowed.includes(lowered)) { + return lowered; + } + return allowed.find((policy) => typeof policy !== "string") ?? requested; +} + +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}.`; +} + +const CODEX_REQUIREMENTS_READ_TIMEOUT_MS = 5_000; + +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.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), + ), + ), + ); + 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 +684,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 +742,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 +758,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 +771,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 +836,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 +877,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 +1322,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 +2388,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 +2399,7 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + ...(managedRequirements ? { requirements: managedRequirements } : {}), }); const providerThreadId = opened.thread.id; @@ -2255,7 +2412,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 +2473,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 } : {}),