diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 6f9fbe53d911a..b620cb7e7f8d7 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -65,9 +65,11 @@ function makeThread(input: { const grokInstanceId = ProviderInstanceId.make("grok"); const antigravityInstanceId = ProviderInstanceId.make("antigravity"); +const managedCodexInstanceId = ProviderInstanceId.make("codex-managed"); const supportedRuntimeModesByInstance = new Map>([ [grokInstanceId, ["approval-required", "auto", "full-access"]], [antigravityInstanceId, ["approval-required", "auto-accept-edits", "full-access"]], + [managedCodexInstanceId, ["auto-accept-edits", "auto"]], ]); const providerInstanceFor = (instanceId: ProviderInstanceId) => ({ @@ -157,6 +159,24 @@ it.layer(layerTest)("RuntimePolicyV2", (it) => { assert.equal(yield* modeFor(providerInstanceId, "auto-accept-edits"), "auto-accept-edits"); }), ); + + it.effect("steps down to the broadest mode Codex allows when it forbids Supervised", () => + Effect.gen(function* () { + const policy = yield* RuntimePolicy.RuntimePolicyV2; + const now = yield* DateTime.now; + const modeFor = (runtimeMode: RuntimeMode) => + policy + .resolve({ + thread: makeThread({ now, worktreePath: null, runtimeMode }), + modelSelection: { instanceId: managedCodexInstanceId, model: "gpt-5.5" }, + }) + .pipe(Effect.map((resolved) => resolved.runtimeMode)); + + assert.equal(yield* modeFor("full-access"), "auto"); + assert.equal(yield* modeFor("auto-accept-edits"), "auto-accept-edits"); + assert.equal(yield* modeFor("approval-required"), "approval-required"); + }), + ); }); it.layer( diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.ts b/apps/server/src/orchestration-v2/RuntimePolicy.ts index f0d0ae38577d1..65c0bcec326e9 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.ts @@ -1,5 +1,6 @@ import { isRuntimeModeAbove, + providerRuntimeMode, managedRuntimeModeCeilingMessage, ModelSelection, OrchestrationV2AppThread, @@ -88,17 +89,6 @@ export const layer: Layer.Layer = Layer.succeed(RuntimePolicyV2 }), }); -function providerRuntimeMode( - runtimeMode: RuntimeMode, - supportedRuntimeModes: ReadonlyArray | undefined, -): RuntimeMode { - return supportedRuntimeModes === undefined || - supportedRuntimeModes.length === 0 || - supportedRuntimeModes.includes(runtimeMode) - ? runtimeMode - : "approval-required"; -} - const refuseAboveCeiling = ( managedPolicy: ServerSettings.ServerSettingsService["Service"]["managedPolicy"], thread: OrchestrationV2AppThread, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index df105d3ba20fe..204bace840968 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -27,6 +27,7 @@ import type { ProviderOptionDescriptor, ServerProviderModel, ServerProviderSkill, + RuntimeMode, } from "@t3tools/contracts"; import { PREFERRED_DEFAULT_CODEX_MODELS, ServerSettingsError } from "@t3tools/contracts"; @@ -37,6 +38,7 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { codexRuntimeModesAllowedByRequirements } from "./codexRuntimeModeRequirements.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -159,6 +161,7 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; + readonly supportedRuntimeModes?: ReadonlyArray | undefined; /** Undefined when `config/read` failed, so T3 cannot know Codex's default program. */ readonly daybreakEnabled?: boolean | undefined; readonly cyberAccessProgramsUnavailable?: boolean | undefined; @@ -728,45 +731,63 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + input.probeTimeoutMs - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, { config, nativeUsage, rateLimits }] = yield* Effect.all( - [ - withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), - accountResponse.account?.type === "chatgpt" - ? requestCodexChatGptModels({ - client, - codexHome: initialize.codexHome, - spawnedAtMs, - deadlineMs, - }) - : withCodexDiscoveryDeadline(requestAllCodexModels(client), deadlineMs), - // The shared catalog must exclude config from the server's launch project. - withCodexDiscoveryDeadline( - client.request("config/read", { includeLayers: false }), - deadlineMs, - CODEX_CONFIG_PROBE_TIMEOUT_MS, - ).pipe( - Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), - Effect.flatMap((config) => { - // Only a confirmed native route may probe or publish the login's quota. - const nativeUsage = - !input.skipNativeUsage && - config !== undefined && - codexRoutesSpendNativeQuota(accountResponse, config.config); - const readUsage: Effect.Effect = nativeUsage - ? readCodexRateLimits(client, deadlineMs) - : Effect.undefined; - return readUsage.pipe( - Effect.map((rateLimits) => ({ - config: config?.config, - nativeUsage, - rateLimits, - })), - ); - }), - ), - ], - { concurrency: "unbounded" }, - ); + const [skillsResponse, models, { config, nativeUsage, rateLimits }, supportedRuntimeModes] = + yield* Effect.all( + [ + withCodexDiscoveryDeadline( + client.request("skills/list", { cwds: [input.cwd] }), + deadlineMs, + ), + accountResponse.account?.type === "chatgpt" + ? requestCodexChatGptModels({ + client, + codexHome: initialize.codexHome, + spawnedAtMs, + deadlineMs, + }) + : withCodexDiscoveryDeadline(requestAllCodexModels(client), deadlineMs), + // The shared catalog must exclude config from the server's launch project. + withCodexDiscoveryDeadline( + client.request("config/read", { includeLayers: false }), + deadlineMs, + CODEX_CONFIG_PROBE_TIMEOUT_MS, + ).pipe( + Effect.catch(() => + Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined)), + ), + Effect.flatMap((config) => { + // Only a confirmed native route may probe or publish the login's quota. + const nativeUsage = + !input.skipNativeUsage && + config !== undefined && + codexRoutesSpendNativeQuota(accountResponse, config.config); + const readUsage: Effect.Effect = nativeUsage + ? readCodexRateLimits(client, deadlineMs) + : Effect.undefined; + return readUsage.pipe( + Effect.map((rateLimits) => ({ + config: config?.config, + nativeUsage, + rateLimits, + })), + ); + }), + ), + // Managed requirements can forbid a sandbox mode, and Codex then refuses + // to start the turn, so delegation has to know which modes are allowed. + withCodexDiscoveryDeadline( + client.request("configRequirements/read", undefined), + deadlineMs, + CODEX_CONFIG_PROBE_TIMEOUT_MS, + ).pipe( + Effect.map((response) => codexRuntimeModesAllowedByRequirements(response?.requirements)), + Effect.catch(() => + Effect.logDebug("Codex config requirements read failed.").pipe(Effect.as(undefined)), + ), + ), + ], + { concurrency: "unbounded" }, + ); const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { @@ -777,6 +798,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun version, models, serviceTier: config?.service_tier ?? undefined, + supportedRuntimeModes, daybreakEnabled: config === undefined ? undefined : cyberAccess?.daybreak === true, // An unread config cannot rule out an API key with the programs turned off. cyberAccessProgramsUnavailable: @@ -1063,7 +1085,12 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu }); return buildServerProvider({ - presentation: CODEX_PRESENTATION, + presentation: { + ...CODEX_PRESENTATION, + ...(snapshot.supportedRuntimeModes + ? { supportedRuntimeModes: snapshot.supportedRuntimeModes } + : {}), + }, enabled: codexSettings.enabled, checkedAt, models: pipe( diff --git a/apps/server/src/provider/codexRuntimeModeRequirements.test.ts b/apps/server/src/provider/codexRuntimeModeRequirements.test.ts new file mode 100644 index 0000000000000..a6f4fd206a827 --- /dev/null +++ b/apps/server/src/provider/codexRuntimeModeRequirements.test.ts @@ -0,0 +1,37 @@ +import * as NodeAssert from "node:assert/strict"; + +import { describe, it } from "vite-plus/test"; + +import { codexRuntimeModesAllowedByRequirements } from "./codexRuntimeModeRequirements.ts"; + +describe("codexRuntimeModesAllowedByRequirements", () => { + it("leaves every mode available when nothing restricts Codex", () => { + NodeAssert.equal(codexRuntimeModesAllowedByRequirements(null), undefined); + NodeAssert.equal(codexRuntimeModesAllowedByRequirements({}), undefined); + }); + + it("drops full access when managed requirements forbid the danger-full-access sandbox", () => { + NodeAssert.deepStrictEqual( + codexRuntimeModesAllowedByRequirements({ + allowedSandboxModes: ["read-only", "workspace-write"], + }), + ["approval-required", "auto-accept-edits", "auto"], + ); + }); + + it("drops modes whose approval policy is forbidden", () => { + NodeAssert.deepStrictEqual( + codexRuntimeModesAllowedByRequirements({ + allowedApprovalPolicies: ["untrusted", "on-request"], + }), + ["approval-required", "auto-accept-edits", "auto"], + ); + NodeAssert.deepStrictEqual( + codexRuntimeModesAllowedByRequirements({ + allowedSandboxModes: ["read-only", "workspace-write", "danger-full-access"], + allowedApprovalPolicies: ["untrusted"], + }), + ["approval-required"], + ); + }); +}); diff --git a/apps/server/src/provider/codexRuntimeModeRequirements.ts b/apps/server/src/provider/codexRuntimeModeRequirements.ts new file mode 100644 index 0000000000000..03dc6cd14434c --- /dev/null +++ b/apps/server/src/provider/codexRuntimeModeRequirements.ts @@ -0,0 +1,41 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import type * as CodexSchema from "effect-codex-app-server/schema"; + +type CodexRequirements = NonNullable; + +// Mirrors codexRuntimeModeTurnDefaults in CodexAdapterV2, which can't be +// imported here because the adapter already imports this provider. +const CODEX_RUNTIME_MODE_POLICIES: ReadonlyArray<{ + readonly runtimeMode: RuntimeMode; + readonly sandboxMode: CodexSchema.V2ConfigRequirementsReadResponse__SandboxMode; + readonly approvalPolicy: "untrusted" | "on-request" | "never"; +}> = [ + { runtimeMode: "approval-required", sandboxMode: "read-only", approvalPolicy: "untrusted" }, + { + runtimeMode: "auto-accept-edits", + sandboxMode: "workspace-write", + approvalPolicy: "on-request", + }, + { runtimeMode: "auto", sandboxMode: "workspace-write", approvalPolicy: "on-request" }, + { runtimeMode: "full-access", sandboxMode: "danger-full-access", approvalPolicy: "never" }, +]; + +/** + * The runtime modes Codex accepts under its managed requirements + * (requirements.toml or MDM). Undefined when nothing restricts them, so every + * mode stays available. + */ +export function codexRuntimeModesAllowedByRequirements( + requirements: CodexRequirements | null | undefined, +): ReadonlyArray | undefined { + const allowedSandboxModes = requirements?.allowedSandboxModes ?? undefined; + const allowedApprovalPolicies = requirements?.allowedApprovalPolicies ?? undefined; + if (allowedSandboxModes === undefined && allowedApprovalPolicies === undefined) { + return undefined; + } + return CODEX_RUNTIME_MODE_POLICIES.filter( + ({ sandboxMode, approvalPolicy }) => + (allowedSandboxModes === undefined || allowedSandboxModes.includes(sandboxMode)) && + (allowedApprovalPolicies === undefined || allowedApprovalPolicies.includes(approvalPolicy)), + ).map(({ runtimeMode }) => runtimeMode); +} diff --git a/apps/web/src/components/chat/runtimeModeConfig.ts b/apps/web/src/components/chat/runtimeModeConfig.ts index 3702641e816f9..9437558aed760 100644 --- a/apps/web/src/components/chat/runtimeModeConfig.ts +++ b/apps/web/src/components/chat/runtimeModeConfig.ts @@ -1,6 +1,7 @@ import { managedRuntimeModeDenial, type ManagedPolicySummary, + providerRuntimeMode, type RuntimeMode, } from "@t3tools/contracts"; import { @@ -49,7 +50,7 @@ export function resolveCompatibleRuntimeMode( supportedRuntimeModes && supportedRuntimeModes.length > 0 ? runtimeModeOptions.filter((mode) => supportedRuntimeModes.includes(mode)) : runtimeModeOptions; - return { modes, mode: modes.includes(runtimeMode) ? runtimeMode : (modes[0] ?? runtimeMode) }; + return { modes, mode: providerRuntimeMode(runtimeMode, supportedRuntimeModes) }; } export function resolveRuntimeModeOptions( diff --git a/packages/contracts/src/providerPolicy.ts b/packages/contracts/src/providerPolicy.ts index 1b94f43aabd5c..c0c07ac6e2371 100644 --- a/packages/contracts/src/providerPolicy.ts +++ b/packages/contracts/src/providerPolicy.ts @@ -42,6 +42,28 @@ const RUNTIME_MODE_RANK: Record = { export const isRuntimeModeAbove = (mode: RuntimeMode, ceiling: RuntimeMode | undefined): boolean => ceiling !== undefined && RUNTIME_MODE_RANK[mode] > RUNTIME_MODE_RANK[ceiling]; +/** The mode a provider actually runs a thread in, given the modes it supports (all when none). */ +export function providerRuntimeMode( + runtimeMode: RuntimeMode, + supportedRuntimeModes: ReadonlyArray | undefined, +): RuntimeMode { + if ( + supportedRuntimeModes === undefined || + supportedRuntimeModes.length === 0 || + supportedRuntimeModes.includes(runtimeMode) + ) { + return runtimeMode; + } + if (supportedRuntimeModes.includes("approval-required")) return "approval-required"; + // Codex's managed requirements can forbid the read-only sandbox Supervised needs, so step down + // to the broadest mode it still allows rather than one it will refuse to start. + return ( + RuntimeMode.literals.findLast( + (mode) => supportedRuntimeModes.includes(mode) && !isRuntimeModeAbove(mode, runtimeMode), + ) ?? runtimeMode + ); +} + export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default";