From 0605c852966b51e9f565d6b957ac20b4e2cef314 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Thu, 29 Oct 2026 14:02:31 +0000 Subject: [PATCH 01/15] feat(codex): choose a cyber access program for Daybreak-eligible models Codex's app server only applies Daybreak when each turn/start carries cyberAccessProgram; its config.toml `daybreak` setting does not reach app-server turns. T3 never sent the field, and the turn/start encode schema dropped it. Models whose catalog entry lists a Daybreak program in availableAccessPrograms.cyber now get a Cyber Access select. Its default, Automatic, omits the field so Codex keeps its own behaviour. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/codexModelOptions.ts | 10 +++ .../Adapters/CodexAdapterV2.test.ts | 89 +++++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 9 +- .../server/src/provider/CodexProvider.test.ts | 41 +++++++++ apps/server/src/provider/CodexProvider.ts | 28 ++++++ .../effect-codex-app-server/src/client.ts | 1 + 6 files changed, 177 insertions(+), 1 deletion(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 43b8a3c184f5..0c7776c964a1 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -12,3 +12,13 @@ export function getCodexServiceTierOptionValue( (getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true ? "fast" : undefined) ); } + +export const CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID = "automatic"; + +/** Undefined leaves Codex's automatic cyber treatment in place. */ +export function getCodexCyberAccessProgramOptionValue( + modelSelection: ModelSelection | null | undefined, +): string | undefined { + const value = getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); + return value === CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID ? undefined : value; +} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 55a1e66ee725..23bdb262d965 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -225,6 +225,27 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); + it.effect("leaves Codex's automatic cyber access in place unless a program is chosen", () => + Effect.gen(function* () { + const build = (value?: string) => + CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-cyber", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), + }, + }); + + assert.notProperty(yield* build(), "cyberAccessProgram"); + assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); + assert.equal((yield* build("standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + }), + ); + it.effect("sends MCP app model context as untrusted Codex context", () => Effect.gen(function* () { const policy = { runtimeMode: "full-access", interactionMode: "default", cwd: null } as const; @@ -2402,6 +2423,74 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + it.effect("sends the selected cyber access program on the wire", () => + Effect.scoped( + Effect.gen(function* () { + const nativeThreadId = "cyber-thread"; + const nativeTurnId = "cyber-turn"; + const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "review" }); + const transcript = makeCodexReplayTranscript({ + scenario: "cyber-access-program", + entries: [ + ...entries.slice(0, 5), + { + type: "expect_outbound", + label: "turn/start", + frame: { + id: 3, + method: "turn/start", + params: { + threadId: nativeThreadId, + input: [{ type: "text", text: "review" }], + cwd: "/workspace", + model: "gpt-5.4", + approvalPolicy: "never", + approvalsReviewer: "user", + sandboxPolicy: { type: "dangerFullAccess" }, + summary: "detailed", + cyberAccessProgram: "daybreakBlue", + collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, + }, + }, + }, + ...entries.slice(6), + { + type: "emit_inbound", + label: "done", + frame: { + method: "turn/completed", + params: { + threadId: nativeThreadId, + turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), + }, + }, + }, + ], + }); + const harness = yield* makeCodexReplayHarness(transcript); + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("cyber-attempt"), + text: "review", + }), + modelSelection: { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }); + yield* harness.firstTerminal; + assert.equal(harness.terminalEvents()[0]?.status, "completed"); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect("preserves T3 context on the wire and restores it after compaction", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 997547cdb330..bb5c245d96ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -104,7 +104,10 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as PortScanner from "../../preview/PortScanner.ts"; -import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; +import { + getCodexCyberAccessProgramOptionValue, + getCodexServiceTierOptionValue, +} from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { buildCodexAdditionalContext } from "../../provider/CodexDeveloperInstructions.ts"; @@ -708,6 +711,7 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), }), ); type CodexTurnStartParamsWithCollaborationMode = @@ -791,6 +795,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); + const cyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -826,6 +831,8 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), + // Codex keeps no per-thread choice for this, so every turn must carry it. + ...(cyberAccessProgram === undefined ? {} : { cyberAccessProgram }), // Codex supplies its own mode instructions; send both modes to clear Plan on exit. collaborationMode: { mode: input.runtimePolicy.interactionMode, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 30f63b21f318..fffeb798132c 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -153,3 +153,44 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("offers cyber access programs only when the catalog has a Daybreak program", () => { + const model = { + additionalSpeedTiers: [], + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }; + + assert.deepStrictEqual( + mapCodexModelCapabilities({ + ...model, + availableAccessPrograms: { cyber: ["standard"] }, + }).optionDescriptors, + [], + ); + assert.deepStrictEqual( + mapCodexModelCapabilities({ + ...model, + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + }).optionDescriptors, + [ + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "automatic", label: "Automatic", isDefault: true }, + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue: "automatic", + }, + ], + ); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2e56eede84cd..1496430f07be 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -53,6 +53,7 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; +import { CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID } from "../codexModelOptions.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -114,6 +115,12 @@ const SERVICE_TIER_DESCRIPTIONS: Readonly> = { ultrafast: "Even faster, more expensive", }; +const CYBER_ACCESS_PROGRAM_LABELS: Readonly> = { + standard: "Standard", + daybreakBlue: "Daybreak Blue", + daybreakRed: "Daybreak Red", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -242,6 +249,27 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; + // Only Daybreak programmes need a choice; standard-only models keep their usual controls. + if (cyberAccessPrograms.some((program) => program !== "standard")) { + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { + id: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + label: "Automatic", + isDefault: true, + }, + ...cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + })), + ], + currentValue: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + }); + } return createModelCapabilities({ optionDescriptors, diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 28f677e03ee0..c0644ea4e966 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -92,6 +92,7 @@ const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pip additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), }), ); From 819036b917030ad8642c4fa9afd30cabf6b2126b Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Thu, 29 Oct 2026 15:33:47 +0000 Subject: [PATCH 02/15] fix(codex): custom models do not inherit Daybreak access A bare custom Codex slug borrows the first built-in model's options, so it could show Cyber Access even though Daybreak eligibility is per model. Strip that option from borrowed capabilities, type the turn/start field with the turn/start schema, and document the setting. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.ts | 2 +- .../server/src/provider/CodexProvider.test.ts | 32 ++++++++++++++++++- apps/server/src/provider/CodexProvider.ts | 12 +++++-- docs/user/providers-codex.md | 8 +++++ .../effect-codex-app-server/src/client.ts | 2 +- 5 files changed, 50 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index bb5c245d96ed..95f2ceeae817 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -711,7 +711,7 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); type CodexTurnStartParamsWithCollaborationMode = diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index fffeb798132c..2a41b437740e 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,6 +1,10 @@ import { assert, it } from "@effect/vitest"; -import { applyPreferredCodexDefaultModel, mapCodexModelCapabilities } from "./CodexProvider.ts"; +import { + appendCustomCodexModels, + applyPreferredCodexDefaultModel, + mapCodexModelCapabilities, +} from "./CodexProvider.ts"; it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ @@ -194,3 +198,29 @@ it("offers cyber access programs only when the catalog has a Daybreak program", ], ); }); + +it("keeps Daybreak access off custom models that borrow built-in options", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + }); + const models = appendCustomCodexModels( + [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }], + [{ slug: "my-model" }], + ); + + const optionIds = (slug: string) => + models + .find((model) => model.slug === slug) + ?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id); + assert.deepStrictEqual(optionIds("gpt-test"), ["reasoningEffort", "cyberAccessProgram"]); + assert.deepStrictEqual(optionIds("my-model"), ["reasoningEffort"]); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 1496430f07be..ef2107ea84ea 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -343,13 +343,19 @@ function applyConfiguredCodexServiceTier( /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps - * them. + * them. Daybreak access is per model, so a borrowed slug never inherits it. */ -function appendCustomCodexModels( +export function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { - const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; + const borrowed = models.find((model) => model.capabilities)?.capabilities ?? null; + const fallbackCapabilities = borrowed && { + ...borrowed, + optionDescriptors: borrowed.optionDescriptors?.filter( + (descriptor) => descriptor.id !== "cyberAccessProgram", + ), + }; return providerModelsFromSettings(models, customModels, fallbackCapabilities); } diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 3384831aa87b..71473c61daa1 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -100,6 +100,14 @@ in the thread on web, desktop, or mobile. Some tools offer access for one reques the current session, or permanently. See [Permission modes](./permission-modes.md) for command and file approvals. +## Use Daybreak + +If your ChatGPT account has Daybreak access, models that offer it show a Cyber +Access choice in the model options next to the composer. Automatic, the +default, leaves the choice to Codex. Pick Daybreak Blue or another programme to +send it with every turn in that thread. Models without Daybreak, including +custom models, do not show the choice. + ## Codex says I hit a usage limit When Codex stops on a usage limit, the thread names the window that ran out and diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0644ea4e966..4899c055f6f6 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -92,7 +92,7 @@ const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pip additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2ModelListResponse__CyberAccessProgram), + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); From 44db3e082714bbbf11d594cd79d47750bd0fe92c Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Sat, 31 Oct 2026 01:33:49 +0000 Subject: [PATCH 03/15] docs(codex): explain Daybreak login errors --- docs/user/providers-codex.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 71473c61daa1..55322964fe28 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -108,6 +108,10 @@ default, leaves the choice to Codex. Pick Daybreak Blue or another programme to send it with every turn in that thread. Models without Daybreak, including custom models, do not show the choice. +If Codex reports that you are not logged in with a Daybreak key, sign in to +Codex again using the appropriate Daybreak credentials for your account, then +retry the turn. Use the same Codex home as the provider instance in T3 Code. + ## Codex says I hit a usage limit When Codex stops on a usage limit, the thread names the window that ran out and From 5085584d3167e00e0e06f1e2f04f29799176815f Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Sat, 31 Oct 2026 22:45:04 +0000 Subject: [PATCH 04/15] fix(codex): default cyber access to Standard, matching Codex Omitting cyberAccessProgram leaves the choice to OpenAI's server, while Codex's own clients send Standard unless Daybreak is on. Models in the catalog now default to Standard, or to Daybreak when Codex's daybreak setting is on, and T3 sends the program on every turn. Turns with no selection use the model's default from the provider snapshot. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/codexModelOptions.ts | 6 +- .../Adapters/CodexAdapterV2.test.ts | 13 ++- .../Adapters/CodexAdapterV2.ts | 19 +++- .../server/src/provider/CodexProvider.test.ts | 105 +++++++++++++----- apps/server/src/provider/CodexProvider.ts | 96 +++++++++++++--- .../src/provider/Drivers/CodexDriver.ts | 5 + .../provider/Drivers/CodexManagedProvider.ts | 5 + docs/user/providers-codex.md | 11 +- 8 files changed, 200 insertions(+), 60 deletions(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 0c7776c964a1..54ba4b100e15 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -13,12 +13,8 @@ export function getCodexServiceTierOptionValue( ); } -export const CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID = "automatic"; - -/** Undefined leaves Codex's automatic cyber treatment in place. */ export function getCodexCyberAccessProgramOptionValue( modelSelection: ModelSelection | null | undefined, ): string | undefined { - const value = getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); - return value === CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID ? undefined : value; + return getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); } diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 23bdb262d965..766897673be9 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -225,9 +225,9 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("leaves Codex's automatic cyber access in place unless a program is chosen", () => + it.effect("sends the chosen cyber access program, else the model's default", () => Effect.gen(function* () { - const build = (value?: string) => + const build = (value?: string, defaultCyberAccessProgram?: string) => CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-cyber", codexInput: [{ type: "text", text: "test" }], @@ -237,12 +237,15 @@ describe("CodexAdapterV2 runtime policy", () => { model: "gpt-5.4", ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), }, + defaultCyberAccessProgram, }); + // Custom models have no catalog entry, so Codex keeps its own treatment. assert.notProperty(yield* build(), "cyberAccessProgram"); - assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); - assert.equal((yield* build("standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build(undefined, "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("automatic", "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("daybreakBlue", "standard")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); }), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 95f2ceeae817..160e3fe30208 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -764,6 +764,8 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { } } +const isCodexCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); + export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( function* (input: { readonly nativeThreadId: string; @@ -776,6 +778,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; + readonly defaultCyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = @@ -795,7 +798,10 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const cyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); + const selectedCyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); + const cyberAccessProgram = [selectedCyberAccessProgram, input.defaultCyberAccessProgram].find( + isCodexCyberAccessProgram, + ); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -1661,7 +1667,10 @@ export type CodexAdapterV2DriverEnv = export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapterV2")(function* ( { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + CodexAdapterV2Options, + "onUsageLimits" | "resolveRuntime" | "compatibility" | "defaultCyberAccessProgram" + > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; const continuationRequests = yield* ProviderContinuationRequests.ProviderContinuationRequests; @@ -1711,6 +1720,7 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; + readonly defaultCyberAccessProgram?: (model: string) => Effect.Effect; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -6858,7 +6868,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const defaultCyberAccessProgram = + adapterOptions.defaultCyberAccessProgram === undefined + ? undefined + : yield* adapterOptions.defaultCyberAccessProgram(turnInput.modelSelection.model); const turnStartParams = yield* buildCodexTurnStartParams({ + defaultCyberAccessProgram, nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 2a41b437740e..9a5383b7c89e 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -3,6 +3,8 @@ import { assert, it } from "@effect/vitest"; import { appendCustomCodexModels, applyPreferredCodexDefaultModel, + applyConfiguredCodexCyberAccess, + codexDefaultCyberAccessProgram, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -158,7 +160,7 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); -it("offers cyber access programs only when the catalog has a Daybreak program", () => { +it("defaults cyber access to Standard when the catalog offers it", () => { const model = { additionalSpeedTiers: [], defaultReasoningEffort: "medium", @@ -170,33 +172,86 @@ it("offers cyber access programs only when the catalog has a Daybreak program", model: "gpt-test", supportedReasoningEfforts: [], }; - - assert.deepStrictEqual( - mapCodexModelCapabilities({ - ...model, - availableAccessPrograms: { cyber: ["standard"] }, - }).optionDescriptors, - [], - ); - assert.deepStrictEqual( + const cyber = (programs?: ReadonlyArray<"standard" | "daybreakBlue">) => mapCodexModelCapabilities({ ...model, - availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, - }).optionDescriptors, - [ - { - id: "cyberAccessProgram", - label: "Cyber Access", - type: "select", - options: [ - { id: "automatic", label: "Automatic", isDefault: true }, - { id: "standard", label: "Standard" }, - { id: "daybreakBlue", label: "Daybreak Blue" }, - ], - currentValue: "automatic", - }, + ...(programs ? { availableAccessPrograms: { cyber: [...programs] } } : {}), + }).optionDescriptors?.[0]; + + assert.strictEqual(cyber(), undefined); + assert.deepStrictEqual(cyber(["standard", "daybreakBlue"]), { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, ], - ); + currentValue: "standard", + }); + assert.deepInclude(cyber(["standard"]), { currentValue: "standard" }); + assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); +}); + +it("finds the default cyber access program for a model", () => { + const capabilities = mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }); + const models = [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }]; + assert.strictEqual(codexDefaultCyberAccessProgram(models, "gpt-test"), "standard"); + assert.strictEqual(codexDefaultCyberAccessProgram(models, "my-model"), undefined); +}); + +it("follows Codex's daybreak setting for the default program", () => { + const capabilities = (cyber: ReadonlyArray<"standard" | "daybreakBlue">) => + mapCodexModelCapabilities({ + additionalSpeedTiers: [], + availableAccessPrograms: { cyber: [...cyber] }, + defaultReasoningEffort: "medium", + description: "Test model", + displayName: "GPT Test", + hidden: false, + id: "gpt-test", + isDefault: true, + model: "gpt-test", + supportedReasoningEfforts: [], + }); + const models = [ + { + slug: "both", + name: "Both", + isCustom: false, + capabilities: capabilities(["standard", "daybreakBlue"]), + }, + { + slug: "standard", + name: "Standard", + isCustom: false, + capabilities: capabilities(["standard"]), + }, + ]; + + const on = applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: true, + apiKeyProgramsDisabled: false, + }); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "both"), "daybreakBlue"); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "standard"), "standard"); + + const disabled = applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: false, + apiKeyProgramsDisabled: true, + }); + assert.strictEqual(codexDefaultCyberAccessProgram(disabled, "both"), undefined); }); it("keeps Daybreak access off custom models that borrow built-in options", () => { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index ef2107ea84ea..abf01c463ce5 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -53,7 +53,6 @@ import { type CodexRateLimitSnapshot, type CodexResetCreditsSummary, } from "./codexUsageLimits.ts"; -import { CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID } from "../codexModelOptions.ts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import packageJson from "../../package.json" with { type: "json" }; const isCodexAppServerSpawnError = Schema.is(CodexErrors.CodexAppServerSpawnError); @@ -95,6 +94,8 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; + readonly daybreakEnabled?: boolean | undefined; + readonly apiKeyCyberAccessProgramsDisabled?: boolean | undefined; readonly skills: ReadonlyArray; } @@ -250,24 +251,21 @@ export function mapCodexModelCapabilities( }); } const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; - // Only Daybreak programmes need a choice; standard-only models keep their usual controls. - if (cyberAccessPrograms.some((program) => program !== "standard")) { + if (cyberAccessPrograms.length > 0) { + // Codex's own clients request Standard unless Daybreak is on; omitting the field does not. + const defaultProgram = cyberAccessPrograms.includes("standard") + ? "standard" + : cyberAccessPrograms[0]; optionDescriptors.push({ id: "cyberAccessProgram", label: "Cyber Access", type: "select", - options: [ - { - id: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, - label: "Automatic", - isDefault: true, - }, - ...cyberAccessPrograms.map((program) => ({ - id: program, - label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, - })), - ], - currentValue: CODEX_AUTOMATIC_CYBER_ACCESS_PROGRAM_ID, + options: cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + ...(program === defaultProgram ? { isDefault: true } : {}), + })), + ...(defaultProgram === undefined ? {} : { currentValue: defaultProgram }), }); } @@ -309,6 +307,58 @@ export function applyPreferredCodexDefaultModel( }); } +/** Mirrors Codex: `daybreak = true` prefers a Daybreak program, Blue before Red. */ +export function applyConfiguredCodexCyberAccess( + models: ReadonlyArray, + input: { + readonly daybreakEnabled: boolean; + readonly apiKeyProgramsDisabled: boolean; + }, +): ReadonlyArray { + if (!input.daybreakEnabled && !input.apiKeyProgramsDisabled) return models; + return models.map((model) => { + const descriptors = model.capabilities?.optionDescriptors; + if (!model.capabilities || !descriptors) return model; + return { + ...model, + capabilities: { + ...model.capabilities, + // Codex rejects explicit programs in this session, so none can be offered or sent. + optionDescriptors: (input.apiKeyProgramsDisabled + ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") + : descriptors + ).map((descriptor) => { + if (descriptor.id !== "cyberAccessProgram" || descriptor.type !== "select") { + return descriptor; + } + const value = ["daybreakBlue", "daybreakRed"].find((program) => + descriptor.options.some((option) => option.id === program), + ); + if (!value) return descriptor; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => + option.id === value ? { ...option, isDefault: true } : option, + ), + }; + }), + }, + }; + }); +} + +/** Applies when a selection names no program, such as older threads and agent-started turns. */ +export function codexDefaultCyberAccessProgram( + models: ReadonlyArray, + slug: string, +): string | undefined { + const descriptor = models + .find((model) => model.slug === slug) + ?.capabilities?.optionDescriptors?.find(({ id }) => id === "cyberAccessProgram"); + return descriptor?.type === "select" ? descriptor.currentValue : undefined; +} + function applyConfiguredCodexServiceTier( models: ReadonlyArray, serviceTier: string | undefined, @@ -586,7 +636,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun // Optional discovery must finish before the outer timeout can discard the account result. const deadlineMs = spawnedAtMs + CODEX_AUTH_PROBE_TIMEOUT_MS - CODEX_DISCOVERY_MARGIN_MS; - const [skillsResponse, models, serviceTier, rateLimits] = yield* Effect.all( + const [skillsResponse, models, config, rateLimits] = yield* Effect.all( [ withCodexDiscoveryDeadline(client.request("skills/list", { cwds: [input.cwd] }), deadlineMs), accountResponse.account?.type === "chatgpt" @@ -603,7 +653,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun deadlineMs, CODEX_CONFIG_PROBE_TIMEOUT_MS, ).pipe( - Effect.map((response) => response?.config.service_tier ?? undefined), + Effect.map((response) => response?.config), Effect.catch(() => Effect.logDebug("Codex config read failed.").pipe(Effect.as(undefined))), ), // Usage is an enrichment: a failure or a slow answer degrades to "no @@ -640,7 +690,12 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun ...(rateLimits ? { rateLimits } : {}), version, models, - serviceTier, + serviceTier: config?.service_tier ?? undefined, + daybreakEnabled: config?.["daybreak"] === true, + apiKeyCyberAccessProgramsDisabled: + accountResponse.account?.type === "apiKey" && + (config?.["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) + ?.api_key_cyber_access_programs === false, skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -904,6 +959,11 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), applyPreferredCodexDefaultModel, (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), + (models) => + applyConfiguredCodexCyberAccess(models, { + daybreakEnabled: snapshot.daybreakEnabled === true, + apiKeyProgramsDisabled: snapshot.apiKeyCyberAccessProgramsDisabled === true, + }), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index dee8a04aee9f..e33a3233fcc6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -27,6 +27,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -215,6 +216,10 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + defaultCyberAccessProgram: (model) => + snapshot.getSnapshot.pipe( + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + ), compatibility: installation.compatibility, }, ).pipe( diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index ab5e7add556a..a8c8a043f6e0 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,6 +20,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -244,6 +245,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), + defaultCyberAccessProgram: (model) => + snapshot.getSnapshot.pipe( + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + ), resolveRuntime: runtime.resolve, }).pipe( Effect.mapError( diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 55322964fe28..1e3aa300c746 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -102,11 +102,12 @@ for command and file approvals. ## Use Daybreak -If your ChatGPT account has Daybreak access, models that offer it show a Cyber -Access choice in the model options next to the composer. Automatic, the -default, leaves the choice to Codex. Pick Daybreak Blue or another programme to -send it with every turn in that thread. Models without Daybreak, including -custom models, do not show the choice. +Models that list cyber access programs show a Cyber Access choice in the model +options next to the composer. T3 Code sends it with every turn. As in the Codex +CLI, the default is Standard unless you set `daybreak = true` in Codex's +`config.toml`. Your last choice stays selected for new threads until you change +it, so check it before starting unrelated work. Models that only offer Daybreak +stay on Daybreak. Custom models do not show the choice. If Codex reports that you are not logged in with a Daybreak key, sign in to Codex again using the appropriate Daybreak credentials for your account, then From df7b215da0bd0948fffbcdf9183777689dd55937 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Fri, 6 Nov 2026 00:11:10 +0000 Subject: [PATCH 05/15] fix(codex): send only a cyber access program the model offers A selection kept from another model was forwarded as-is, and an explicit program still went out when API-key sessions had programs disabled. The provider now resolves the program against the model's current catalog entry, falling back to its default and sending nothing when the model offers none. A failed config read on an API-key account counts as disabled, since Codex rejects programs it does not allow. The wire test also provides NodeCrypto, which turn start now needs. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 25 +++++++++-------- .../Adapters/CodexAdapterV2.ts | 26 +++++++++--------- .../server/src/provider/CodexProvider.test.ts | 27 ++++++++++++++----- apps/server/src/provider/CodexProvider.ts | 25 ++++++++++++----- .../src/provider/Drivers/CodexDriver.ts | 6 ++--- .../provider/Drivers/CodexManagedProvider.ts | 6 ++--- 6 files changed, 70 insertions(+), 45 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 766897673be9..b3b6458b39a1 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -225,27 +225,21 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("sends the chosen cyber access program, else the model's default", () => + it.effect("sends only a cyber access program Codex accepts", () => Effect.gen(function* () { - const build = (value?: string, defaultCyberAccessProgram?: string) => + const build = (cyberAccessProgram?: string) => CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-cyber", codexInput: [{ type: "text", text: "test" }], runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), - }, - defaultCyberAccessProgram, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + cyberAccessProgram, }); // Custom models have no catalog entry, so Codex keeps its own treatment. assert.notProperty(yield* build(), "cyberAccessProgram"); - assert.equal((yield* build(undefined, "standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("automatic", "standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("daybreakBlue", "standard")).cyberAccessProgram, "daybreakBlue"); - assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); + assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); + assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); }), ); @@ -2488,7 +2482,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { assert.equal(harness.terminalEvents()[0]?.status, "completed"); }).pipe( Effect.provide( - Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), ), ), ), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 160e3fe30208..83bbc0382f01 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -778,7 +778,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; - readonly defaultCyberAccessProgram?: string | undefined; + readonly cyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = @@ -798,10 +798,9 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const selectedCyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); - const cyberAccessProgram = [selectedCyberAccessProgram, input.defaultCyberAccessProgram].find( - isCodexCyberAccessProgram, - ); + const cyberAccessProgram = isCodexCyberAccessProgram(input.cyberAccessProgram) + ? input.cyberAccessProgram + : undefined; // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -1669,7 +1668,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, hooks: Pick< CodexAdapterV2Options, - "onUsageLimits" | "resolveRuntime" | "compatibility" | "defaultCyberAccessProgram" + "onUsageLimits" | "resolveRuntime" | "compatibility" | "resolveCyberAccessProgram" > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; @@ -1720,7 +1719,10 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; - readonly defaultCyberAccessProgram?: (model: string) => Effect.Effect; + /** Checks the selection against the model's current catalog entry, falling back to its default. */ + readonly resolveCyberAccessProgram?: ( + modelSelection: ModelSelection, + ) => Effect.Effect; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -6868,12 +6870,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); - const defaultCyberAccessProgram = - adapterOptions.defaultCyberAccessProgram === undefined - ? undefined - : yield* adapterOptions.defaultCyberAccessProgram(turnInput.modelSelection.model); + const cyberAccessProgram = + adapterOptions.resolveCyberAccessProgram === undefined + ? getCodexCyberAccessProgramOptionValue(turnInput.modelSelection) + : yield* adapterOptions.resolveCyberAccessProgram(turnInput.modelSelection); const turnStartParams = yield* buildCodexTurnStartParams({ - defaultCyberAccessProgram, + cyberAccessProgram, nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 9a5383b7c89e..c6610cd99b90 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,13 +1,21 @@ import { assert, it } from "@effect/vitest"; +import { ProviderInstanceId, type ServerProviderModel } from "@t3tools/contracts"; import { appendCustomCodexModels, applyPreferredCodexDefaultModel, applyConfiguredCodexCyberAccess, - codexDefaultCyberAccessProgram, + codexCyberAccessProgram, mapCodexModelCapabilities, } from "./CodexProvider.ts"; +const cyberProgram = (models: ReadonlyArray, model: string, value?: string) => + codexCyberAccessProgram(models, { + instanceId: ProviderInstanceId.make("codex"), + model, + ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), + }); + it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], @@ -193,7 +201,7 @@ it("defaults cyber access to Standard when the catalog offers it", () => { assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); }); -it("finds the default cyber access program for a model", () => { +it("resolves the cyber access program against the model's catalog entry", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, @@ -207,8 +215,12 @@ it("finds the default cyber access program for a model", () => { supportedReasoningEfforts: [], }); const models = [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }]; - assert.strictEqual(codexDefaultCyberAccessProgram(models, "gpt-test"), "standard"); - assert.strictEqual(codexDefaultCyberAccessProgram(models, "my-model"), undefined); + assert.strictEqual(cyberProgram(models, "gpt-test"), "standard"); + assert.strictEqual(cyberProgram(models, "gpt-test", "daybreakBlue"), "daybreakBlue"); + // A selection kept from another model, or a value the model does not offer. + assert.strictEqual(cyberProgram(models, "gpt-test", "daybreakRed"), "standard"); + assert.strictEqual(cyberProgram(models, "gpt-test", "automatic"), "standard"); + assert.strictEqual(cyberProgram(models, "my-model", "daybreakBlue"), undefined); }); it("follows Codex's daybreak setting for the default program", () => { @@ -244,14 +256,15 @@ it("follows Codex's daybreak setting for the default program", () => { daybreakEnabled: true, apiKeyProgramsDisabled: false, }); - assert.strictEqual(codexDefaultCyberAccessProgram(on, "both"), "daybreakBlue"); - assert.strictEqual(codexDefaultCyberAccessProgram(on, "standard"), "standard"); + assert.strictEqual(cyberProgram(on, "both"), "daybreakBlue"); + assert.strictEqual(cyberProgram(on, "standard"), "standard"); const disabled = applyConfiguredCodexCyberAccess(models, { daybreakEnabled: false, apiKeyProgramsDisabled: true, }); - assert.strictEqual(codexDefaultCyberAccessProgram(disabled, "both"), undefined); + assert.strictEqual(cyberProgram(disabled, "both"), undefined); + assert.strictEqual(cyberProgram(disabled, "both", "daybreakBlue"), undefined); }); it("keeps Daybreak access off custom models that borrow built-in options", () => { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index abf01c463ce5..1b1f97f099ef 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -23,6 +23,7 @@ import type { ServerProvider, ServerProviderState, ModelCapabilities, + ModelSelection, ProviderOptionDescriptor, ServerProviderModel, ServerProviderSkill, @@ -36,6 +37,7 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; +import { getCodexCyberAccessProgramOptionValue } from "../codexModelOptions.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -348,15 +350,22 @@ export function applyConfiguredCodexCyberAccess( }); } -/** Applies when a selection names no program, such as older threads and agent-started turns. */ -export function codexDefaultCyberAccessProgram( +/** + * A selection the model does not offer, such as one kept from another model, + * gets the model's default, as do older threads and agent-started turns. + */ +export function codexCyberAccessProgram( models: ReadonlyArray, - slug: string, + modelSelection: ModelSelection, ): string | undefined { + const selected = getCodexCyberAccessProgramOptionValue(modelSelection); const descriptor = models - .find((model) => model.slug === slug) + .find((model) => model.slug === modelSelection.model) ?.capabilities?.optionDescriptors?.find(({ id }) => id === "cyberAccessProgram"); - return descriptor?.type === "select" ? descriptor.currentValue : undefined; + if (descriptor?.type !== "select") return undefined; + return descriptor.options.some((option) => option.id === selected) + ? selected + : descriptor.currentValue; } function applyConfiguredCodexServiceTier( @@ -692,10 +701,12 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun models, serviceTier: config?.service_tier ?? undefined, daybreakEnabled: config?.["daybreak"] === true, + // An unread config may have them disabled, and Codex rejects a program it does not allow. apiKeyCyberAccessProgramsDisabled: accountResponse.account?.type === "apiKey" && - (config?.["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) - ?.api_key_cyber_access_programs === false, + (config === undefined || + (config["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) + ?.api_key_cyber_access_programs === false), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index e33a3233fcc6..d72113753b17 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -27,7 +27,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, - codexDefaultCyberAccessProgram, + codexCyberAccessProgram, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -216,9 +216,9 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - defaultCyberAccessProgram: (model) => + resolveCyberAccessProgram: (modelSelection) => snapshot.getSnapshot.pipe( - Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + Effect.map((value) => codexCyberAccessProgram(value.models, modelSelection)), ), compatibility: installation.compatibility, }, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index a8c8a043f6e0..890f74e8baa9 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,7 +20,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, - codexDefaultCyberAccessProgram, + codexCyberAccessProgram, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -245,9 +245,9 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - defaultCyberAccessProgram: (model) => + resolveCyberAccessProgram: (modelSelection) => snapshot.getSnapshot.pipe( - Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), + Effect.map((value) => codexCyberAccessProgram(value.models, modelSelection)), ), resolveRuntime: runtime.resolve, }).pipe( From ffe1a1b7356f672cb4325bbe4bd6a32d557c8293 Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Fri, 6 Nov 2026 00:46:36 +0000 Subject: [PATCH 06/15] test(codex): an unread config disables API-key cyber access programs Co-Authored-By: Claude Opus 5.5 --- .../server/src/provider/CodexProvider.test.ts | 11 +++++++++ apps/server/src/provider/CodexProvider.ts | 23 ++++++++++++++----- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index c6610cd99b90..6bf4bc16a109 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -5,6 +5,7 @@ import { appendCustomCodexModels, applyPreferredCodexDefaultModel, applyConfiguredCodexCyberAccess, + codexApiKeyCyberAccessProgramsDisabled, codexCyberAccessProgram, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -267,6 +268,16 @@ it("follows Codex's daybreak setting for the default program", () => { assert.strictEqual(cyberProgram(disabled, "both", "daybreakBlue"), undefined); }); +it("treats an unread config as disabling API-key cyber access programs", () => { + const apiKey = { type: "apiKey" } as const; + const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; + const off = { features: { api_key_cyber_access_programs: false } }; + assert.isTrue(codexApiKeyCyberAccessProgramsDisabled(apiKey, undefined)); + assert.isTrue(codexApiKeyCyberAccessProgramsDisabled(apiKey, off)); + assert.isFalse(codexApiKeyCyberAccessProgramsDisabled(apiKey, {})); + assert.isFalse(codexApiKeyCyberAccessProgramsDisabled(chatgpt, undefined)); +}); + it("keeps Daybreak access off custom models that borrow built-in options", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 1b1f97f099ef..8b6c1bea1405 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -368,6 +368,19 @@ export function codexCyberAccessProgram( : descriptor.currentValue; } +/** An unread config may have them disabled, and Codex rejects a program it does not allow. */ +export function codexApiKeyCyberAccessProgramsDisabled( + account: CodexSchema.V2GetAccountResponse["account"], + config: Readonly> | undefined, +): boolean { + return ( + account?.type === "apiKey" && + (config === undefined || + (config["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) + ?.api_key_cyber_access_programs === false) + ); +} + function applyConfiguredCodexServiceTier( models: ReadonlyArray, serviceTier: string | undefined, @@ -701,12 +714,10 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun models, serviceTier: config?.service_tier ?? undefined, daybreakEnabled: config?.["daybreak"] === true, - // An unread config may have them disabled, and Codex rejects a program it does not allow. - apiKeyCyberAccessProgramsDisabled: - accountResponse.account?.type === "apiKey" && - (config === undefined || - (config["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) - ?.api_key_cyber_access_programs === false), + apiKeyCyberAccessProgramsDisabled: codexApiKeyCyberAccessProgramsDisabled( + accountResponse.account, + config, + ), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); From ec2d965a85805080c0e6384f9a74d76e580f449e Mon Sep 17 00:00:00 2001 From: Martin Meredith Date: Fri, 6 Nov 2026 00:53:20 +0000 Subject: [PATCH 07/15] fix(codex): send the chosen cyber access program even when the model lacks it #27835 asks that a choice T3 cannot honour fail visibly instead of being replaced. Falling back to the model's default could turn a Standard choice into Daybreak on a Daybreak-only model. An explicit, recognised choice is sent again and Codex rejects it; the default applies only without one. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 18 ++++++++----- .../Adapters/CodexAdapterV2.ts | 26 +++++++++--------- .../server/src/provider/CodexProvider.test.ts | 27 +++++-------------- apps/server/src/provider/CodexProvider.ts | 19 ++++--------- .../src/provider/Drivers/CodexDriver.ts | 6 ++--- .../provider/Drivers/CodexManagedProvider.ts | 6 ++--- 6 files changed, 42 insertions(+), 60 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index b3b6458b39a1..0294be3c6c3f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -225,21 +225,27 @@ describe("CodexAdapterV2 runtime policy", () => { }), ); - it.effect("sends only a cyber access program Codex accepts", () => + it.effect("sends the chosen cyber access program, else the model's default", () => Effect.gen(function* () { - const build = (cyberAccessProgram?: string) => + const build = (value?: string, defaultCyberAccessProgram?: string) => CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-cyber", codexInput: [{ type: "text", text: "test" }], runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, - cyberAccessProgram, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), + }, + defaultCyberAccessProgram, }); // Custom models have no catalog entry, so Codex keeps its own treatment. assert.notProperty(yield* build(), "cyberAccessProgram"); - assert.notProperty(yield* build("automatic"), "cyberAccessProgram"); - assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build(undefined, "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("automatic", "standard")).cyberAccessProgram, "standard"); + assert.equal((yield* build("daybreakBlue", "standard")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); }), ); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 83bbc0382f01..160e3fe30208 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -778,7 +778,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; - readonly cyberAccessProgram?: string | undefined; + readonly defaultCyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = @@ -798,9 +798,10 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const cyberAccessProgram = isCodexCyberAccessProgram(input.cyberAccessProgram) - ? input.cyberAccessProgram - : undefined; + const selectedCyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); + const cyberAccessProgram = [selectedCyberAccessProgram, input.defaultCyberAccessProgram].find( + isCodexCyberAccessProgram, + ); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -1668,7 +1669,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, hooks: Pick< CodexAdapterV2Options, - "onUsageLimits" | "resolveRuntime" | "compatibility" | "resolveCyberAccessProgram" + "onUsageLimits" | "resolveRuntime" | "compatibility" | "defaultCyberAccessProgram" > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; @@ -1719,10 +1720,7 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; - /** Checks the selection against the model's current catalog entry, falling back to its default. */ - readonly resolveCyberAccessProgram?: ( - modelSelection: ModelSelection, - ) => Effect.Effect; + readonly defaultCyberAccessProgram?: (model: string) => Effect.Effect; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -6870,12 +6868,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); - const cyberAccessProgram = - adapterOptions.resolveCyberAccessProgram === undefined - ? getCodexCyberAccessProgramOptionValue(turnInput.modelSelection) - : yield* adapterOptions.resolveCyberAccessProgram(turnInput.modelSelection); + const defaultCyberAccessProgram = + adapterOptions.defaultCyberAccessProgram === undefined + ? undefined + : yield* adapterOptions.defaultCyberAccessProgram(turnInput.modelSelection.model); const turnStartParams = yield* buildCodexTurnStartParams({ - cyberAccessProgram, + defaultCyberAccessProgram, nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 6bf4bc16a109..eba44b5c214a 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,22 +1,14 @@ import { assert, it } from "@effect/vitest"; -import { ProviderInstanceId, type ServerProviderModel } from "@t3tools/contracts"; import { appendCustomCodexModels, applyPreferredCodexDefaultModel, applyConfiguredCodexCyberAccess, codexApiKeyCyberAccessProgramsDisabled, - codexCyberAccessProgram, + codexDefaultCyberAccessProgram, mapCodexModelCapabilities, } from "./CodexProvider.ts"; -const cyberProgram = (models: ReadonlyArray, model: string, value?: string) => - codexCyberAccessProgram(models, { - instanceId: ProviderInstanceId.make("codex"), - model, - ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), - }); - it("maps current Codex model capability fields", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], @@ -202,7 +194,7 @@ it("defaults cyber access to Standard when the catalog offers it", () => { assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); }); -it("resolves the cyber access program against the model's catalog entry", () => { +it("finds the default cyber access program for a model", () => { const capabilities = mapCodexModelCapabilities({ additionalSpeedTiers: [], availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, @@ -216,12 +208,8 @@ it("resolves the cyber access program against the model's catalog entry", () => supportedReasoningEfforts: [], }); const models = [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }]; - assert.strictEqual(cyberProgram(models, "gpt-test"), "standard"); - assert.strictEqual(cyberProgram(models, "gpt-test", "daybreakBlue"), "daybreakBlue"); - // A selection kept from another model, or a value the model does not offer. - assert.strictEqual(cyberProgram(models, "gpt-test", "daybreakRed"), "standard"); - assert.strictEqual(cyberProgram(models, "gpt-test", "automatic"), "standard"); - assert.strictEqual(cyberProgram(models, "my-model", "daybreakBlue"), undefined); + assert.strictEqual(codexDefaultCyberAccessProgram(models, "gpt-test"), "standard"); + assert.strictEqual(codexDefaultCyberAccessProgram(models, "my-model"), undefined); }); it("follows Codex's daybreak setting for the default program", () => { @@ -257,15 +245,14 @@ it("follows Codex's daybreak setting for the default program", () => { daybreakEnabled: true, apiKeyProgramsDisabled: false, }); - assert.strictEqual(cyberProgram(on, "both"), "daybreakBlue"); - assert.strictEqual(cyberProgram(on, "standard"), "standard"); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "both"), "daybreakBlue"); + assert.strictEqual(codexDefaultCyberAccessProgram(on, "standard"), "standard"); const disabled = applyConfiguredCodexCyberAccess(models, { daybreakEnabled: false, apiKeyProgramsDisabled: true, }); - assert.strictEqual(cyberProgram(disabled, "both"), undefined); - assert.strictEqual(cyberProgram(disabled, "both", "daybreakBlue"), undefined); + assert.strictEqual(codexDefaultCyberAccessProgram(disabled, "both"), undefined); }); it("treats an unread config as disabling API-key cyber access programs", () => { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 8b6c1bea1405..1eedc39ae428 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -23,7 +23,6 @@ import type { ServerProvider, ServerProviderState, ModelCapabilities, - ModelSelection, ProviderOptionDescriptor, ServerProviderModel, ServerProviderSkill, @@ -37,7 +36,6 @@ import { } from "@t3tools/shared/model"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { codexAppServerArgs, resolveCodexLaunchArgs } from "./codexLaunchArgs.ts"; -import { getCodexCyberAccessProgramOptionValue } from "../codexModelOptions.ts"; import { buildServerProvider, COMPACT_SLASH_COMMAND, @@ -350,22 +348,15 @@ export function applyConfiguredCodexCyberAccess( }); } -/** - * A selection the model does not offer, such as one kept from another model, - * gets the model's default, as do older threads and agent-started turns. - */ -export function codexCyberAccessProgram( +/** Applies when a selection names no program, such as older threads and agent-started turns. */ +export function codexDefaultCyberAccessProgram( models: ReadonlyArray, - modelSelection: ModelSelection, + slug: string, ): string | undefined { - const selected = getCodexCyberAccessProgramOptionValue(modelSelection); const descriptor = models - .find((model) => model.slug === modelSelection.model) + .find((model) => model.slug === slug) ?.capabilities?.optionDescriptors?.find(({ id }) => id === "cyberAccessProgram"); - if (descriptor?.type !== "select") return undefined; - return descriptor.options.some((option) => option.id === selected) - ? selected - : descriptor.currentValue; + return descriptor?.type === "select" ? descriptor.currentValue : undefined; } /** An unread config may have them disabled, and Codex rejects a program it does not allow. */ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index d72113753b17..e33a3233fcc6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -27,7 +27,7 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, - codexCyberAccessProgram, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -216,9 +216,9 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - resolveCyberAccessProgram: (modelSelection) => + defaultCyberAccessProgram: (model) => snapshot.getSnapshot.pipe( - Effect.map((value) => codexCyberAccessProgram(value.models, modelSelection)), + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), ), compatibility: installation.compatibility, }, diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index 890f74e8baa9..a8c8a043f6e0 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,7 +20,7 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, - codexCyberAccessProgram, + codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -245,9 +245,9 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu // already wraps openSession in withAccess, so resolve without re-entering it. const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - resolveCyberAccessProgram: (modelSelection) => + defaultCyberAccessProgram: (model) => snapshot.getSnapshot.pipe( - Effect.map((value) => codexCyberAccessProgram(value.models, modelSelection)), + Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), ), resolveRuntime: runtime.resolve, }).pipe( From 20fe5c139f5862839b18b94e12a01a900c29ed4e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:43:17 +0000 Subject: [PATCH 08/15] test(codex): drop a cyber access test the Daybreak case already covers AI-assisted; verified with the tests and tools listed above. --- apps/server/src/provider/CodexProvider.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index eba44b5c214a..c474dcd3f929 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -194,24 +194,6 @@ it("defaults cyber access to Standard when the catalog offers it", () => { assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); }); -it("finds the default cyber access program for a model", () => { - const capabilities = mapCodexModelCapabilities({ - additionalSpeedTiers: [], - availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, - defaultReasoningEffort: "medium", - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - supportedReasoningEfforts: [], - }); - const models = [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }]; - assert.strictEqual(codexDefaultCyberAccessProgram(models, "gpt-test"), "standard"); - assert.strictEqual(codexDefaultCyberAccessProgram(models, "my-model"), undefined); -}); - it("follows Codex's daybreak setting for the default program", () => { const capabilities = (cyber: ReadonlyArray<"standard" | "daybreakBlue">) => mapCodexModelCapabilities({ From 29f5fefec3c7919f73c66d8e5643fecb1d753150 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:55:56 +0000 Subject: [PATCH 09/15] fix(codex): keep Standard selectable on Daybreak-only models AI-assisted; verified with the tests and tools listed above. --- .../server/src/provider/CodexProvider.test.ts | 25 ++++++++++++++++++- apps/server/src/provider/CodexProvider.ts | 6 ++++- docs/user/providers-codex.md | 3 ++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index c474dcd3f929..c453ef54be00 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,4 +1,8 @@ import { assert, it } from "@effect/vitest"; +import { + buildExplicitProviderOptionSelectionsFromDescriptors, + getProviderOptionDescriptors, +} from "@t3tools/shared/model"; import { appendCustomCodexModels, @@ -191,7 +195,26 @@ it("defaults cyber access to Standard when the catalog offers it", () => { currentValue: "standard", }); assert.deepInclude(cyber(["standard"]), { currentValue: "standard" }); - assert.deepInclude(cyber(["daybreakBlue"]), { currentValue: "daybreakBlue" }); + const daybreakOnly = cyber(["daybreakBlue"]); + assert.deepInclude(daybreakOnly, { + options: [ + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, + ], + currentValue: "daybreakBlue", + }); + // Clients normalize selections against these descriptors before dispatch. + const selections = [{ id: "cyberAccessProgram", value: "standard" }]; + assert.deepStrictEqual( + buildExplicitProviderOptionSelectionsFromDescriptors( + getProviderOptionDescriptors({ + caps: { optionDescriptors: daybreakOnly ? [daybreakOnly] : [] }, + selections, + }), + selections, + ), + selections, + ); }); it("follows Codex's daybreak setting for the default program", () => { diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 1eedc39ae428..de69726dc7a3 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -260,7 +260,11 @@ export function mapCodexModelCapabilities( id: "cyberAccessProgram", label: "Cyber Access", type: "select", - options: cyberAccessPrograms.map((program) => ({ + // Standard stays selectable as an opt-out; Codex rejects it visibly if the account can't use it. + options: (cyberAccessPrograms.includes("standard") + ? cyberAccessPrograms + : ["standard", ...cyberAccessPrograms] + ).map((program) => ({ id: program, label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, ...(program === defaultProgram ? { isDefault: true } : {}), diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 1e3aa300c746..5bd15562defb 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -107,7 +107,8 @@ options next to the composer. T3 Code sends it with every turn. As in the Codex CLI, the default is Standard unless you set `daybreak = true` in Codex's `config.toml`. Your last choice stays selected for new threads until you change it, so check it before starting unrelated work. Models that only offer Daybreak -stay on Daybreak. Custom models do not show the choice. +start on Daybreak, but you can still pick Standard to opt out. Custom models do +not show the choice. If Codex reports that you are not logged in with a Daybreak key, sign in to Codex again using the appropriate Daybreak credentials for your account, then From dfb2be63abfc81255beca333efee3750a7c105cd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 14:29:11 -0800 Subject: [PATCH 10/15] test(codex): keep the cyber access tests to the behaviors the PR claims Replace the per-helper cyber access tests with checks at the surfaces a rebuild must keep: the provider status's model list (catalog-only selector, Standard/Daybreak defaults, API-key opt-out), turn/start params (selection over default, custom models omit it), and the encoded wire frame carrying an explicit Standard. Add the expectation that remote delegation drops an inherited cyber access program, which fails until that lands. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 94 ++--------- apps/server/src/peer/RemoteDelegation.test.ts | 21 ++- .../server/src/provider/CodexProvider.test.ts | 150 ++---------------- .../src/provider/ProviderRegistry.test.ts | 75 ++++++++- 4 files changed, 113 insertions(+), 227 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 563b5edaf946..b2a95f1df2dd 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -242,11 +242,9 @@ describe("CodexAdapterV2 runtime policy", () => { defaultCyberAccessProgram, }); - // Custom models have no catalog entry, so Codex keeps its own treatment. + // Custom models have no catalog default. assert.notProperty(yield* build(), "cyberAccessProgram"); - assert.equal((yield* build(undefined, "standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("automatic", "standard")).cyberAccessProgram, "standard"); - assert.equal((yield* build("daybreakBlue", "standard")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build(undefined, "daybreakBlue")).cyberAccessProgram, "daybreakBlue"); assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); }), ); @@ -2501,91 +2499,24 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); - it.effect("sends the selected cyber access program on the wire", () => - Effect.scoped( - Effect.gen(function* () { - const nativeThreadId = "cyber-thread"; - const nativeTurnId = "cyber-turn"; - const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "review" }); - const transcript = makeCodexReplayTranscript({ - scenario: "cyber-access-program", - entries: [ - ...entries.slice(0, 5), - { - type: "expect_outbound", - label: "turn/start", - frame: { - id: 3, - method: "turn/start", - params: { - threadId: nativeThreadId, - input: [{ type: "text", text: "review" }], - cwd: "/workspace", - model: "gpt-5.4", - approvalPolicy: "never", - approvalsReviewer: "user", - sandboxPolicy: { type: "dangerFullAccess" }, - summary: "detailed", - cyberAccessProgram: "daybreakBlue", - collaborationMode: { mode: "default", settings: { model: "gpt-5.4" } }, - }, - }, - }, - ...entries.slice(6), - { - type: "emit_inbound", - label: "done", - frame: { - method: "turn/completed", - params: { - threadId: nativeThreadId, - turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }), - }, - }, - }, - ], - }); - const harness = yield* makeCodexReplayHarness(transcript); - yield* harness.runtime.startTurn({ - ...makeCodexTestTurnInput({ - threadId: harness.threadId, - providerThread: harness.providerThread, - now: yield* DateTime.now, - attemptId: RunAttemptId.make("cyber-attempt"), - text: "review", - }), - modelSelection: { - ...CODEX_TEST_MODEL_SELECTION, - options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], - }, - }); - yield* harness.firstTerminal; - assert.equal(harness.terminalEvents()[0]?.status, "completed"); - }).pipe( - Effect.provide( - Layer.mergeAll( - IdAllocator.layer, - McpProviderSessions.layer, - NodeServices.layer, - NodeCrypto.layer, - ), - ), - ), - ), - ); - it.effect("preserves T3 context on the wire and restores it after compaction", () => Effect.scoped( Effect.gen(function* () { const nativeThreadId = "context-thread"; const nativeTurnId = "context-turn"; + // An explicit Standard must reach Codex, or Codex falls back to Daybreak. + const modelSelection = { + ...CODEX_TEST_MODEL_SELECTION, + options: [{ id: "cyberAccessProgram", value: "standard" }], + }; const params = yield* CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId, codexInput: [{ type: "text", text: "work" }], runtimePolicy: CODEX_TEST_RUNTIME_POLICY, - modelSelection: CODEX_TEST_MODEL_SELECTION, + modelSelection, hasT3Mcp: true, }); + assert.equal(params.cyberAccessProgram, "standard"); assert.include( params.additionalContext?.t3_code_orchestration_01?.value ?? "", "delegate_task", @@ -2674,15 +2605,16 @@ describe("CodexAdapterV2 post-settle continuation", () => { authorizationHeader: "Bearer test", browserToolsAvailable: true, }); - yield* harness.runtime.startTurn( - makeCodexTestTurnInput({ + yield* harness.runtime.startTurn({ + ...makeCodexTestTurnInput({ threadId: harness.threadId, providerThread: harness.providerThread, now: yield* DateTime.now, attemptId: RunAttemptId.make("context-attempt"), text: "work", }), - ); + modelSelection, + }); yield* harness.firstTerminal; yield* Deferred.await(restoreRequested); assert.equal(harness.terminalEvents()[0]?.status, "completed"); diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index cb46d63ab623..6fe9e8a85b39 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -6,6 +6,7 @@ import { CommandId, EventId, MessageId, + type ModelSelection, NodeId, type OrchestrationV2Run, ProjectId, @@ -653,7 +654,10 @@ const parentScope: McpInvocationContext.McpInvocationScope = { }; /** The laptop's parent thread, mid-turn when its agent delegates. */ -const seedParent = (laptopEnv: Effect.Success>) => +const seedParent = ( + laptopEnv: Effect.Success>, + parentModelSelection: ModelSelection = modelSelection, +) => Effect.gen(function* () { const now = yield* DateTime.now; const providerThreadId = ProviderThreadId.make("provider-thread:laptop-parent"); @@ -665,7 +669,7 @@ const seedParent = (laptopEnv: Effect.Success>) => threadId: parentThreadId, projectId: ProjectId.make("project:laptop"), title: "Laptop parent", - modelSelection, + modelSelection: parentModelSelection, runtimeMode: "full-access", interactionMode: "default", branch: null, @@ -739,7 +743,7 @@ const seedParent = (laptopEnv: Effect.Success>) => threadId: parentThreadId, ordinal: 1, providerInstanceId: instanceId, - modelSelection, + modelSelection: parentModelSelection, providerThreadId, userMessageId: MessageId.make("message:seed-user:laptop-parent"), rootNodeId: parentRootNode, @@ -1100,16 +1104,21 @@ it.effect("a task named only by environment inherits the parent's model, within const a = yield* makeLaptop(database, offers); // The parent runs with full access; the link allows less. yield* linkTo(a.links, b, "auto-accept-edits"); - yield* seedParent(a); + const reasoning = { id: "reasoningEffort", value: "high" }; + yield* seedParent(a, { + ...modelSelection, + options: [reasoning, { id: "cyberAccessProgram", value: "daybreakBlue" }], + }); const result = yield* a.call("delegate_task", { task: "Run the suite on the box.", target: { environmentId: box.environmentId }, }); expect(result.isError, JSON.stringify(result.content)).toBe(false); - // The parent's model, and the link's modes rather than the parent's. + // The parent's model and options, but not its cyber access program, which + // stays on this machine; and the link's modes rather than the parent's. expect(yield* Ref.get(thread.launchedWith)).toEqual({ - modelSelection, + modelSelection: { ...modelSelection, options: [reasoning] }, runtimeMode: "auto-accept-edits", interactionMode: "default", }); diff --git a/apps/server/src/provider/CodexProvider.test.ts b/apps/server/src/provider/CodexProvider.test.ts index 693f1aef76a5..7eadf550eab2 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -1,15 +1,7 @@ import { assert, it } from "@effect/vitest"; -import { - buildExplicitProviderOptionSelectionsFromDescriptors, - getProviderOptionDescriptors, -} from "@t3tools/shared/model"; import { - appendCustomCodexModels, applyPreferredCodexDefaultModel, - applyConfiguredCodexCyberAccess, - codexApiKeyCyberAccessProgramsDisabled, - codexDefaultCyberAccessProgram, codexRoutesSpendNativeQuota, mapCodexModelCapabilities, } from "./CodexProvider.ts"; @@ -24,6 +16,7 @@ it("maps current Codex model capability fields", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, defaultServiceTier: "flex", serviceTiers: [ { @@ -73,6 +66,16 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue: "standard", + }, ]); }); @@ -166,137 +169,6 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); -it("defaults cyber access to Standard when the catalog offers it", () => { - const model = { - additionalSpeedTiers: [], - defaultReasoningEffort: "medium", - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - supportedReasoningEfforts: [], - }; - const cyber = (programs?: ReadonlyArray<"standard" | "daybreakBlue">) => - mapCodexModelCapabilities({ - ...model, - ...(programs ? { availableAccessPrograms: { cyber: [...programs] } } : {}), - }).optionDescriptors?.[0]; - - assert.strictEqual(cyber(), undefined); - assert.deepStrictEqual(cyber(["standard", "daybreakBlue"]), { - id: "cyberAccessProgram", - label: "Cyber Access", - type: "select", - options: [ - { id: "standard", label: "Standard", isDefault: true }, - { id: "daybreakBlue", label: "Daybreak Blue" }, - ], - currentValue: "standard", - }); - assert.deepInclude(cyber(["standard"]), { currentValue: "standard" }); - const daybreakOnly = cyber(["daybreakBlue"]); - assert.deepInclude(daybreakOnly, { - options: [ - { id: "standard", label: "Standard" }, - { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, - ], - currentValue: "daybreakBlue", - }); - // Clients normalize selections against these descriptors before dispatch. - const selections = [{ id: "cyberAccessProgram", value: "standard" }]; - assert.deepStrictEqual( - buildExplicitProviderOptionSelectionsFromDescriptors( - getProviderOptionDescriptors({ - caps: { optionDescriptors: daybreakOnly ? [daybreakOnly] : [] }, - selections, - }), - selections, - ), - selections, - ); -}); - -it("follows Codex's daybreak setting for the default program", () => { - const capabilities = (cyber: ReadonlyArray<"standard" | "daybreakBlue">) => - mapCodexModelCapabilities({ - additionalSpeedTiers: [], - availableAccessPrograms: { cyber: [...cyber] }, - defaultReasoningEffort: "medium", - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - supportedReasoningEfforts: [], - }); - const models = [ - { - slug: "both", - name: "Both", - isCustom: false, - capabilities: capabilities(["standard", "daybreakBlue"]), - }, - { - slug: "standard", - name: "Standard", - isCustom: false, - capabilities: capabilities(["standard"]), - }, - ]; - - const on = applyConfiguredCodexCyberAccess(models, { - daybreakEnabled: true, - apiKeyProgramsDisabled: false, - }); - assert.strictEqual(codexDefaultCyberAccessProgram(on, "both"), "daybreakBlue"); - assert.strictEqual(codexDefaultCyberAccessProgram(on, "standard"), "standard"); - - const disabled = applyConfiguredCodexCyberAccess(models, { - daybreakEnabled: false, - apiKeyProgramsDisabled: true, - }); - assert.strictEqual(codexDefaultCyberAccessProgram(disabled, "both"), undefined); -}); - -it("treats an unread config as disabling API-key cyber access programs", () => { - const apiKey = { type: "apiKey" } as const; - const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; - const off = { features: { api_key_cyber_access_programs: false } }; - assert.isTrue(codexApiKeyCyberAccessProgramsDisabled(apiKey, undefined)); - assert.isTrue(codexApiKeyCyberAccessProgramsDisabled(apiKey, off)); - assert.isFalse(codexApiKeyCyberAccessProgramsDisabled(apiKey, {})); - assert.isFalse(codexApiKeyCyberAccessProgramsDisabled(chatgpt, undefined)); -}); - -it("keeps Daybreak access off custom models that borrow built-in options", () => { - const capabilities = mapCodexModelCapabilities({ - additionalSpeedTiers: [], - availableAccessPrograms: { cyber: ["standard", "daybreakBlue"] }, - defaultReasoningEffort: "medium", - description: "Test model", - displayName: "GPT Test", - hidden: false, - id: "gpt-test", - isDefault: true, - model: "gpt-test", - supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], - }); - const models = appendCustomCodexModels( - [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }], - [{ slug: "my-model" }], - ); - - const optionIds = (slug: string) => - models - .find((model) => model.slug === slug) - ?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id); - assert.deepStrictEqual(optionIds("gpt-test"), ["reasoningEffort", "cyberAccessProgram"]); - assert.deepStrictEqual(optionIds("my-model"), ["reasoningEffort"]); -}); - it("reports native quota only for an OpenAI login on a native route", () => { const chatgpt = { type: "chatgpt", email: "a@example.com", planType: "pro" } as const; const hub = { diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 5c53f76237b6..f033bd24d0db 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -44,7 +44,11 @@ import { deepMerge } from "@t3tools/shared/Struct"; import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; -import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; +import { + checkCodexProviderStatus, + mapCodexModelCapabilities, + type CodexAppServerProviderSnapshot, +} from "./CodexProvider.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG } from "./ClaudeModelCatalog.ts"; import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; @@ -481,6 +485,75 @@ it.layer( }), ); + it.effect("offers cyber access only on catalog models that advertise it", () => + Effect.gen(function* () { + const catalogModel = (slug: string, cyber?: Array<"standard" | "daybreakBlue">) => ({ + slug, + name: slug, + isCustom: false, + capabilities: mapCodexModelCapabilities({ + additionalSpeedTiers: [], + ...(cyber ? { availableAccessPrograms: { cyber } } : {}), + defaultReasoningEffort: "medium", + description: slug, + displayName: slug, + hidden: false, + id: slug, + isDefault: false, + model: slug, + supportedReasoningEfforts: [], + }), + }); + const defaultPrograms = (snapshot: Partial) => + checkCodexProviderStatus(decodeCodexSettings({ customModels: ["my-model"] }), () => + Effect.succeed( + makeCodexProbeSnapshot({ + models: [ + catalogModel("both", ["standard", "daybreakBlue"]), + catalogModel("daybreak-only", ["daybreakBlue"]), + catalogModel("plain"), + ], + ...snapshot, + }), + ), + ).pipe( + Effect.map((status) => + Object.fromEntries( + status.models.map((model) => { + const descriptor = model.capabilities?.optionDescriptors?.find( + (option) => option.id === "cyberAccessProgram", + ); + return [ + model.slug, + descriptor?.type === "select" ? descriptor.currentValue : null, + ]; + }), + ), + ), + ); + + assert.deepStrictEqual(yield* defaultPrograms({}), { + both: "standard", + "daybreak-only": "daybreakBlue", + plain: null, + "my-model": null, + }); + assert.deepStrictEqual(yield* defaultPrograms({ daybreakEnabled: true }), { + both: "daybreakBlue", + "daybreak-only": "daybreakBlue", + plain: null, + "my-model": null, + }); + assert.deepStrictEqual( + yield* defaultPrograms({ + account: { account: { type: "apiKey" }, requiresOpenaiAuth: false }, + apiKeyCyberAccessProgramsDisabled: true, + }), + { both: null, "daybreak-only": null, plain: null, "my-model": null }, + ); + }), + ); + it.effect("reads the version of a Codex CLI too old to run app-server (#20351)", () => Effect.gen(function* () { if ((yield* HostProcess.Platform) === "win32") return; From 7aa6fac2656c481114053a2814bc8fc072dce449 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 14:41:36 -0800 Subject: [PATCH 11/15] refactor(codex): resolve the cyber access default from the provider snapshot The proof of concept threaded a per-model default hook from both Codex drivers through the adapter, exported four provider helpers for it, and extended the turn/start schema twice. The adapter now takes the provider's published models, the same source the text generator already reads, and takes the model's default from its option descriptor. Daybreak and API-key handling collapse into one private post-pass beside the service tier one, which now shares its descriptor rewrite, custom models lose the option in that same pass, and the wire schema in the client package is exported and reused by the adapter. Co-Authored-By: Claude Fable 5.2 --- apps/server/src/codexModelOptions.ts | 6 - .../Adapters/CodexAdapterV2.ts | 64 +++--- apps/server/src/peer/RemoteDelegation.ts | 3 +- apps/server/src/provider/CodexProvider.ts | 193 ++++++++---------- .../src/provider/Drivers/CodexDriver.ts | 8 +- .../provider/Drivers/CodexManagedProvider.ts | 9 +- docs/user/providers-codex.md | 16 +- .../effect-codex-app-server/src/client.ts | 5 +- 8 files changed, 134 insertions(+), 170 deletions(-) diff --git a/apps/server/src/codexModelOptions.ts b/apps/server/src/codexModelOptions.ts index 54ba4b100e15..43b8a3c184f5 100644 --- a/apps/server/src/codexModelOptions.ts +++ b/apps/server/src/codexModelOptions.ts @@ -12,9 +12,3 @@ export function getCodexServiceTierOptionValue( (getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true ? "fast" : undefined) ); } - -export function getCodexCyberAccessProgramOptionValue( - modelSelection: ModelSelection | null | undefined, -): string | undefined { - return getModelSelectionStringOptionValue(modelSelection, "cyberAccessProgram"); -} diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 6519d399523f..cc28b661e898 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -38,6 +38,7 @@ import { ProviderDriverKind, ProviderSetupError, type ServerProviderCompatibilityAdvisory, + type ServerProviderModel, isDelegatedAppThread, } from "@t3tools/contracts"; import { SKILL_MENTION_PATTERN } from "@t3tools/shared/composerInlineTokens"; @@ -104,10 +105,7 @@ import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { resolveAttachmentPath, resolveAttachmentPathById } from "../../attachmentStore.ts"; import * as PortScanner from "../../preview/PortScanner.ts"; -import { - getCodexCyberAccessProgramOptionValue, - getCodexServiceTierOptionValue, -} from "../../codexModelOptions.ts"; +import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import { ServerConfig } from "../../config.ts"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { buildCodexAdditionalContext } from "../../provider/CodexDeveloperInstructions.ts"; @@ -708,20 +706,8 @@ const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( Schema.Union([CodexSchema.V2TurnStartParams__ReasoningEffort, Schema.Null]), ); -const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), - additionalContext: Schema.optionalKey( - Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), - ), - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), - }), -); -type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; -const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, -); +type CodexTurnStartParams = typeof CodexClient.V2TurnStartParams.Type; +const decodeCodexTurnStartParams = Schema.decodeUnknownEffect(CodexClient.V2TurnStartParams); const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, ); @@ -769,6 +755,16 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { const isCodexCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); +function codexCatalogCyberAccessProgram( + models: ReadonlyArray, + model: string, +): string | undefined { + const descriptor = models + .find((candidate) => candidate.slug === model) + ?.capabilities?.optionDescriptors?.find((candidate) => candidate.id === "cyberAccessProgram"); + return descriptor?.type === "select" ? descriptor.currentValue : undefined; +} + export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( function* (input: { readonly nativeThreadId: string; @@ -781,6 +777,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; + /** The catalog default; a custom model has none and sends none. */ readonly defaultCyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); @@ -801,10 +798,11 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); - const selectedCyberAccessProgram = getCodexCyberAccessProgramOptionValue(input.modelSelection); - const cyberAccessProgram = [selectedCyberAccessProgram, input.defaultCyberAccessProgram].find( - isCodexCyberAccessProgram, - ); + // A chosen program goes as is, even one the model lacks, so Codex rejects it visibly. + const cyberAccessProgram = [ + getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram"), + input.defaultCyberAccessProgram, + ].find(isCodexCyberAccessProgram); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -826,7 +824,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur t3Context === undefined && Object.keys(appContext).length === 0 ? undefined : { ...t3Context, ...appContext }; - return yield* decodeCodexTurnStartParamsWithCollaborationMode({ + return yield* decodeCodexTurnStartParams({ threadId: input.nativeThreadId, input: input.codexInput, ...(additionalContext ? { additionalContext } : {}), @@ -1688,7 +1686,7 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter { instanceId, environment, enabled, config }: ProviderAdapterDriverCreateInput, hooks: Pick< CodexAdapterV2Options, - "onUsageLimits" | "resolveRuntime" | "compatibility" | "defaultCyberAccessProgram" + "onUsageLimits" | "resolveRuntime" | "compatibility" | "models" > = {}, ) { const clientFactory = yield* CodexAppServerClientFactory; @@ -1739,7 +1737,8 @@ export interface CodexAdapterV2Options { readonly environment: NodeJS.ProcessEnv; readonly clientFactory: CodexAppServerClientFactoryShape; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; - readonly defaultCyberAccessProgram?: (model: string) => Effect.Effect; + /** The provider's published models, read per turn for catalog defaults. */ + readonly models?: Effect.Effect>; readonly resolveRuntime?: Effect.Effect; readonly compatibility?: ( version: string, @@ -1799,10 +1798,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( environment: resolvedRuntime?.environment ?? adapterOptions.environment, }); const additionalContextByThread = yield* Ref.make( - new Map< - string, - NonNullable - >(), + new Map>(), ); const threadsAwaitingContextRestore = yield* Ref.make(new Set()); // Codex drops client developer messages during compaction but only resends additionalContext @@ -7109,12 +7105,12 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ) { const threadId = yield* getNativeThreadId(turnInput.providerThread); const mcpSession = yield* mcpSessions.read(turnInput.threadId); - const defaultCyberAccessProgram = - adapterOptions.defaultCyberAccessProgram === undefined - ? undefined - : yield* adapterOptions.defaultCyberAccessProgram(turnInput.modelSelection.model); + const models = adapterOptions.models === undefined ? [] : yield* adapterOptions.models; const turnStartParams = yield* buildCodexTurnStartParams({ - defaultCyberAccessProgram, + defaultCyberAccessProgram: codexCatalogCyberAccessProgram( + models, + turnInput.modelSelection.model, + ), nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 1222f029eec5..cb75db74baf2 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -233,10 +233,11 @@ const make = Effect.gen(function* () { message: "Pass target.model from orchestrator_capabilities with this environmentId.", }); } + // Cyber access comes with the login on each machine, so it is never inherited across one. const options = target.options ?? (instanceId === inherited.instanceId && model === inherited.model - ? inherited.options + ? inherited.options?.filter((option) => option.id !== "cyberAccessProgram") : undefined); // Modes asked for are checked against this thread. Omitted ones are left // to the other side, which inherits this thread's modes capped by the diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 3ec12617c7ee..87644f21a31c 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -82,6 +82,18 @@ const CodexRoutingConfig = Schema.Struct({ }); const decodeCodexRoutingConfig = Schema.decodeUnknownOption(CodexRoutingConfig); +const CodexCyberAccessConfig = Schema.Struct({ + daybreak: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + features: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + api_key_cyber_access_programs: Schema.optionalKey(Schema.NullOr(Schema.Boolean)), + }), + ), + ), +}); +const decodeCodexCyberAccessConfig = Schema.decodeUnknownOption(CodexCyberAccessConfig); + function routesToNativeOpenai(config: unknown): boolean { const decoded = decodeCodexRoutingConfig(config); if (Option.isNone(decoded)) return false; @@ -291,23 +303,20 @@ export function mapCodexModelCapabilities( } const cyberAccessPrograms = model.availableAccessPrograms?.cyber ?? []; if (cyberAccessPrograms.length > 0) { - // Codex's own clients request Standard unless Daybreak is on; omitting the field does not. - const defaultProgram = cyberAccessPrograms.includes("standard") - ? "standard" - : cyberAccessPrograms[0]; + const hasStandard = cyberAccessPrograms.includes("standard"); + const defaultProgram = hasStandard ? "standard" : cyberAccessPrograms[0]; optionDescriptors.push({ id: "cyberAccessProgram", label: "Cyber Access", type: "select", - // Standard stays selectable as an opt-out; Codex rejects it visibly if the account can't use it. - options: (cyberAccessPrograms.includes("standard") - ? cyberAccessPrograms - : ["standard", ...cyberAccessPrograms] - ).map((program) => ({ - id: program, - label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, - ...(program === defaultProgram ? { isDefault: true } : {}), - })), + // Standard stays selectable as an opt-out; Codex rejects it visibly when the login cannot use it. + options: (hasStandard ? cyberAccessPrograms : ["standard", ...cyberAccessPrograms]).map( + (program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program] ?? program, + ...(program === defaultProgram ? { isDefault: true } : {}), + }), + ), ...(defaultProgram === undefined ? {} : { currentValue: defaultProgram }), }); } @@ -350,68 +359,43 @@ export function applyPreferredCodexDefaultModel( }); } -/** Mirrors Codex: `daybreak = true` prefers a Daybreak program, Blue before Red. */ -export function applyConfiguredCodexCyberAccess( +function mapCodexOptionDescriptors( models: ReadonlyArray, - input: { - readonly daybreakEnabled: boolean; - readonly apiKeyProgramsDisabled: boolean; - }, + update: ( + descriptors: ReadonlyArray, + model: ServerProviderModel, + ) => ReadonlyArray, ): ReadonlyArray { - if (!input.daybreakEnabled && !input.apiKeyProgramsDisabled) return models; return models.map((model) => { - const descriptors = model.capabilities?.optionDescriptors; - if (!model.capabilities || !descriptors) return model; + if (!model.capabilities?.optionDescriptors) return model; return { ...model, capabilities: { ...model.capabilities, - // Codex rejects explicit programs in this session, so none can be offered or sent. - optionDescriptors: (input.apiKeyProgramsDisabled - ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") - : descriptors - ).map((descriptor) => { - if (descriptor.id !== "cyberAccessProgram" || descriptor.type !== "select") { - return descriptor; - } - const value = ["daybreakBlue", "daybreakRed"].find((program) => - descriptor.options.some((option) => option.id === program), - ); - if (!value) return descriptor; - return { - ...descriptor, - currentValue: value, - options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => - option.id === value ? { ...option, isDefault: true } : option, - ), - }; - }), + optionDescriptors: update(model.capabilities.optionDescriptors, model), }, }; }); } -/** Applies when a selection names no program, such as older threads and agent-started turns. */ -export function codexDefaultCyberAccessProgram( +function withConfiguredSelectDefault( models: ReadonlyArray, - slug: string, -): string | undefined { - const descriptor = models - .find((model) => model.slug === slug) - ?.capabilities?.optionDescriptors?.find(({ id }) => id === "cyberAccessProgram"); - return descriptor?.type === "select" ? descriptor.currentValue : undefined; -} - -/** An unread config may have them disabled, and Codex rejects a program it does not allow. */ -export function codexApiKeyCyberAccessProgramsDisabled( - account: CodexSchema.V2GetAccountResponse["account"], - config: Readonly> | undefined, -): boolean { - return ( - account?.type === "apiKey" && - (config === undefined || - (config["features"] as { readonly api_key_cyber_access_programs?: unknown } | undefined) - ?.api_key_cyber_access_programs === false) + descriptorId: string, + pick: (options: ReadonlyArray<{ readonly id: string }>) => string | undefined, +): ReadonlyArray { + return mapCodexOptionDescriptors(models, (descriptors) => + descriptors.map((descriptor) => { + if (descriptor.id !== descriptorId || descriptor.type !== "select") return descriptor; + const value = pick(descriptor.options); + if (!value) return descriptor; + return { + ...descriptor, + currentValue: value, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => + option.id === value ? { ...option, isDefault: true } : option, + ), + }; + }), ); } @@ -420,48 +404,50 @@ function applyConfiguredCodexServiceTier( serviceTier: string | undefined, ): ReadonlyArray { if (!serviceTier) return models; - return models.map((model) => { - if (!model.capabilities?.optionDescriptors) return model; - return { - ...model, - capabilities: { - ...model.capabilities, - optionDescriptors: model.capabilities.optionDescriptors.map((descriptor) => { - if (descriptor.id !== "serviceTier" || descriptor.type !== "select") return descriptor; - const value = descriptor.options.find( - (option) => - option.id === serviceTier || (serviceTier === "fast" && option.id === "priority"), - )?.id; - if (!value) return descriptor; - return { - ...descriptor, - currentValue: value, - options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => - option.id === value ? { ...option, isDefault: true } : option, - ), - }; - }), - }, - }; - }); + return withConfiguredSelectDefault( + models, + "serviceTier", + (options) => + options.find( + (option) => + option.id === serviceTier || (serviceTier === "fast" && option.id === "priority"), + )?.id, + ); +} + +/** Mirrors Codex: `daybreak = true` prefers a Daybreak program, Blue before Red. */ +function applyConfiguredCodexCyberAccess( + models: ReadonlyArray, + snapshot: Pick< + CodexAppServerProviderSnapshot, + "daybreakEnabled" | "apiKeyCyberAccessProgramsDisabled" + >, +): ReadonlyArray { + // Access is per catalog model and per login: a custom slug borrowing descriptors + // and an API key without the programs offer no choice, so no turn sends one. + const offered = mapCodexOptionDescriptors(models, (descriptors, model) => + model.isCustom || snapshot.apiKeyCyberAccessProgramsDisabled === true + ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") + : descriptors, + ); + if (snapshot.daybreakEnabled !== true) return offered; + return withConfiguredSelectDefault(offered, "cyberAccessProgram", (options) => + ["daybreakBlue", "daybreakRed"].find((program) => + options.some((option) => option.id === program), + ), + ); } /** * Codex has no static default capability set, so a bare custom slug borrows * the first built-in's descriptors; an entry with its own capabilities keeps - * them. Daybreak access is per model, so a borrowed slug never inherits it. + * them. */ -export function appendCustomCodexModels( +function appendCustomCodexModels( models: ReadonlyArray, customModels: ReadonlyArray, ): ReadonlyArray { - const borrowed = models.find((model) => model.capabilities)?.capabilities ?? null; - const fallbackCapabilities = borrowed && { - ...borrowed, - optionDescriptors: borrowed.optionDescriptors?.filter( - (descriptor) => descriptor.id !== "cyberAccessProgram", - ), - }; + const fallbackCapabilities = models.find((model) => model.capabilities)?.capabilities ?? null; return providerModelsFromSettings(models, customModels, fallbackCapabilities); } @@ -760,6 +746,7 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun { concurrency: "unbounded" }, ); + const cyberAccess = Option.getOrUndefined(decodeCodexCyberAccessConfig(config)); return { account: accountResponse, nativeUsage, @@ -767,11 +754,11 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun version, models, serviceTier: config?.service_tier ?? undefined, - daybreakEnabled: config?.["daybreak"] === true, - apiKeyCyberAccessProgramsDisabled: codexApiKeyCyberAccessProgramsDisabled( - accountResponse.account, - config, - ), + daybreakEnabled: cyberAccess?.daybreak === true, + // An API key may have the programs turned off, and an unread config cannot rule that out. + apiKeyCyberAccessProgramsDisabled: + accountResponse.account?.type === "apiKey" && + (config === undefined || cyberAccess?.features?.api_key_cyber_access_programs === false), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); @@ -1036,11 +1023,7 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), applyPreferredCodexDefaultModel, (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), - (models) => - applyConfiguredCodexCyberAccess(models, { - daybreakEnabled: snapshot.daybreakEnabled === true, - apiKeyProgramsDisabled: snapshot.apiKeyCyberAccessProgramsDisabled === true, - }), + (models) => applyConfiguredCodexCyberAccess(models, snapshot), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index e33a3233fcc6..f208aee2682b 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -27,7 +27,6 @@ import { makePendingCodexProvider, probeCodexSkillsForCwd, withCodexAppServerClient, - codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { resolveCodexLaunchArgs } from "../codexLaunchArgs.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; @@ -216,10 +215,9 @@ export const CodexDriver: ProviderDriver< }, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - defaultCyberAccessProgram: (model) => - snapshot.getSnapshot.pipe( - Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), - ), + models: Effect.suspend(() => snapshot.getSnapshot).pipe( + Effect.map((value) => value.models), + ), compatibility: installation.compatibility, }, ).pipe( diff --git a/apps/server/src/provider/Drivers/CodexManagedProvider.ts b/apps/server/src/provider/Drivers/CodexManagedProvider.ts index a8c8a043f6e0..f182245f8840 100644 --- a/apps/server/src/provider/Drivers/CodexManagedProvider.ts +++ b/apps/server/src/provider/Drivers/CodexManagedProvider.ts @@ -20,7 +20,6 @@ import { checkCodexProviderStatus, makePendingCodexProvider, probeCodexSkillsForCwd, - codexDefaultCyberAccessProgram, } from "../CodexProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; @@ -243,12 +242,10 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const resolveRuntime = runtime.auth.controller.withAccess!(runtime.resolve); // Launch settings bind the refresh command to the signed-in profile. The registry // already wraps openSession in withAccess, so resolve without re-entering it. + const models = snapshot.getSnapshot.pipe(Effect.map((value) => value.models)); const orchestrationAdapter = yield* createCodexAdapterV2(input, { onUsageLimits: (update) => snapshot.applyUsageLimits(update), - defaultCyberAccessProgram: (model) => - snapshot.getSnapshot.pipe( - Effect.map((value) => codexDefaultCyberAccessProgram(value.models, model)), - ), + models, resolveRuntime: runtime.resolve, }).pipe( Effect.mapError( @@ -264,7 +261,7 @@ export const makeManagedCodexProvider = Effect.fn("makeManagedCodexProvider")(fu const nativeGeneration = yield* makeCodexTextGeneration( config, undefined, - snapshot.getSnapshot.pipe(Effect.map((value) => value.models)), + models, resolveRuntime, ); const protect = (operation: string, effect: Effect.Effect) => diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 5bd15562defb..330c55e39b78 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -102,17 +102,11 @@ for command and file approvals. ## Use Daybreak -Models that list cyber access programs show a Cyber Access choice in the model -options next to the composer. T3 Code sends it with every turn. As in the Codex -CLI, the default is Standard unless you set `daybreak = true` in Codex's -`config.toml`. Your last choice stays selected for new threads until you change -it, so check it before starting unrelated work. Models that only offer Daybreak -start on Daybreak, but you can still pick Standard to opt out. Custom models do -not show the choice. - -If Codex reports that you are not logged in with a Daybreak key, sign in to -Codex again using the appropriate Daybreak credentials for your account, then -retry the turn. Use the same Codex home as the provider instance in T3 Code. +Models with cyber access programs show a Cyber Access option in the model +options. It starts on Standard, or on Daybreak when Codex's `config.toml` sets +`daybreak = true`, and your choice carries over to new threads. Custom models +do not offer it. If Codex rejects a turn because your login lacks the program, +sign in to Codex with the right account and send the message again. ## Codex says I hit a usage limit diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 4899c055f6f6..4bc5873688d2 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -86,7 +86,8 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( +/** The generated `turn/start` schema omits these fields; this is what the wire accepts. */ +export const V2TurnStartParams = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), additionalContext: Schema.optionalKey( @@ -132,7 +133,7 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make | Schema.Codec | undefined => method === "turn/start" - ? (V2TurnStartParamsWithCollaborationMode as never) + ? (V2TurnStartParams as never) : (CodexRpc.CLIENT_REQUEST_PARAMS[method] as never); const getClientRequestResponseSchema = ( From 15ec9d2fe6c9873c234029ff1b08c11a374c5465 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 14:50:36 -0800 Subject: [PATCH 12/15] fix(codex): send a cyber access program only for models that offer one A custom model with an explicit selection, or any model after config/read failed, now sends no program, so Codex's own config stays in charge. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 28 ++++++++++++++++++- .../Adapters/CodexAdapterV2.ts | 13 +++++---- apps/server/src/provider/CodexProvider.ts | 18 ++++++------ .../src/provider/ProviderRegistry.test.ts | 2 +- docs/user/providers-codex.md | 7 +++-- 5 files changed, 49 insertions(+), 19 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index b2a95f1df2dd..d2dfcf7fd790 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -31,6 +31,7 @@ import { RunId, ThreadId, TurnItemId, + type ServerProviderModel, } from "@t3tools/contracts"; import { assert, describe, it } from "@effect/vitest"; import * as CodexClient from "effect-codex-app-server/client"; @@ -242,8 +243,9 @@ describe("CodexAdapterV2 runtime policy", () => { defaultCyberAccessProgram, }); - // Custom models have no catalog default. + // Custom models offer no Cyber Access, so even an explicit choice is dropped. assert.notProperty(yield* build(), "cyberAccessProgram"); + assert.notProperty(yield* build("daybreakBlue"), "cyberAccessProgram"); assert.equal((yield* build(undefined, "daybreakBlue")).cyberAccessProgram, "daybreakBlue"); assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); }), @@ -1264,6 +1266,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { onUsageLimits?: CodexAdapterV2.CodexAdapterV2Options["onUsageLimits"], promptSuggestions: Effect.Effect = Effect.succeed(false), onNotification: (method: string) => Effect.Effect = () => Effect.void, + models?: CodexAdapterV2.CodexAdapterV2Options["models"], ) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -1326,6 +1329,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { environment: {}, clientFactory, ...(onUsageLimits ? { onUsageLimits } : {}), + ...(models ? { models } : {}), digest: yield* Digest.Digest, fileSystem, idAllocator, @@ -2515,6 +2519,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { runtimePolicy: CODEX_TEST_RUNTIME_POLICY, modelSelection, hasT3Mcp: true, + defaultCyberAccessProgram: "daybreakBlue", }); assert.equal(params.cyberAccessProgram, "standard"); assert.include( @@ -2595,6 +2600,27 @@ describe("CodexAdapterV2 post-settle continuation", () => { if (method === "item/completed" || method === "turn/completed") wireOrder.push(method); }), + Effect.succeed>([ + { + slug: modelSelection.model, + name: modelSelection.model, + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "standard", label: "Standard" }, + { id: "daybreakBlue", label: "Daybreak Blue", isDefault: true }, + ], + currentValue: "daybreakBlue", + }, + ], + }, + }, + ]), ); yield* (yield* McpProviderSessions.McpProviderSessions).set({ environmentId: EnvironmentId.make("test"), diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index cc28b661e898..2841c218d629 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -777,7 +777,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur /** ChatGPT token sharing does not accept service tiers. */ readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; - /** The catalog default; a custom model has none and sends none. */ + /** Set only for a model that offers Cyber Access; custom models never do. */ readonly defaultCyberAccessProgram?: string | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); @@ -799,10 +799,13 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ? undefined : getCodexServiceTierOptionValue(input.modelSelection); // A chosen program goes as is, even one the model lacks, so Codex rejects it visibly. - const cyberAccessProgram = [ - getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram"), - input.defaultCyberAccessProgram, - ].find(isCodexCyberAccessProgram); + const cyberAccessProgram = + input.defaultCyberAccessProgram === undefined + ? undefined + : [ + getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram"), + input.defaultCyberAccessProgram, + ].find(isCodexCyberAccessProgram); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 87644f21a31c..789a7db018a5 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -146,7 +146,7 @@ export interface CodexAppServerProviderSnapshot { readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; readonly daybreakEnabled?: boolean | undefined; - readonly apiKeyCyberAccessProgramsDisabled?: boolean | undefined; + readonly cyberAccessProgramsUnavailable?: boolean | undefined; readonly skills: ReadonlyArray; } @@ -420,13 +420,12 @@ function applyConfiguredCodexCyberAccess( models: ReadonlyArray, snapshot: Pick< CodexAppServerProviderSnapshot, - "daybreakEnabled" | "apiKeyCyberAccessProgramsDisabled" + "daybreakEnabled" | "cyberAccessProgramsUnavailable" >, ): ReadonlyArray { - // Access is per catalog model and per login: a custom slug borrowing descriptors - // and an API key without the programs offer no choice, so no turn sends one. + // Without a descriptor no turn sends a program, leaving Codex's own config in charge. const offered = mapCodexOptionDescriptors(models, (descriptors, model) => - model.isCustom || snapshot.apiKeyCyberAccessProgramsDisabled === true + model.isCustom || snapshot.cyberAccessProgramsUnavailable === true ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") : descriptors, ); @@ -755,10 +754,11 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun models, serviceTier: config?.service_tier ?? undefined, daybreakEnabled: cyberAccess?.daybreak === true, - // An API key may have the programs turned off, and an unread config cannot rule that out. - apiKeyCyberAccessProgramsDisabled: - accountResponse.account?.type === "apiKey" && - (config === undefined || cyberAccess?.features?.api_key_cyber_access_programs === false), + // An unread config hides `daybreak` and the API-key switch, so T3 must not pick a program. + cyberAccessProgramsUnavailable: + config === undefined || + (accountResponse.account?.type === "apiKey" && + cyberAccess?.features?.api_key_cyber_access_programs === false), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index f033bd24d0db..439c229fe502 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -547,7 +547,7 @@ it.layer( assert.deepStrictEqual( yield* defaultPrograms({ account: { account: { type: "apiKey" }, requiresOpenaiAuth: false }, - apiKeyCyberAccessProgramsDisabled: true, + cyberAccessProgramsUnavailable: true, }), { both: null, "daybreak-only": null, plain: null, "my-model": null }, ); diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 330c55e39b78..c50e5b18c872 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -104,9 +104,10 @@ for command and file approvals. Models with cyber access programs show a Cyber Access option in the model options. It starts on Standard, or on Daybreak when Codex's `config.toml` sets -`daybreak = true`, and your choice carries over to new threads. Custom models -do not offer it. If Codex rejects a turn because your login lacks the program, -sign in to Codex with the right account and send the message again. +`daybreak = true` or the model only runs under Daybreak. Your choice carries +over to new threads. Custom models do not offer it. If Codex rejects a turn +because your login lacks the program, switch Cyber Access back to Standard or +sign in to Codex with an account that has it. ## Codex says I hit a usage limit From b965de7c49b6cd00739f79b085728301ca8506cb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 15:03:14 -0800 Subject: [PATCH 13/15] fix(codex): an unread Codex config keeps an explicit cyber access choice When config/read fails, the Cyber Access option stays without a default: an explicit Standard still reaches Codex, and no choice sends nothing. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 15 ++++++---- .../Adapters/CodexAdapterV2.ts | 17 +++++------ apps/server/src/provider/CodexProvider.ts | 29 +++++++++++++------ .../src/provider/ProviderRegistry.test.ts | 13 +++++++-- docs/user/providers-codex.md | 4 +-- 5 files changed, 50 insertions(+), 28 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index d2dfcf7fd790..463c8d6bc983 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -230,7 +230,7 @@ describe("CodexAdapterV2 runtime policy", () => { it.effect("sends the chosen cyber access program, else the model's default", () => Effect.gen(function* () { - const build = (value?: string, defaultCyberAccessProgram?: string) => + const build = (value?: string, cyberAccess?: { defaultProgram: string | undefined }) => CodexAdapterV2.buildCodexTurnStartParams({ nativeThreadId: "native-cyber", codexInput: [{ type: "text", text: "test" }], @@ -240,14 +240,19 @@ describe("CodexAdapterV2 runtime policy", () => { model: "gpt-5.4", ...(value === undefined ? {} : { options: [{ id: "cyberAccessProgram", value }] }), }, - defaultCyberAccessProgram, + cyberAccess, }); // Custom models offer no Cyber Access, so even an explicit choice is dropped. assert.notProperty(yield* build(), "cyberAccessProgram"); assert.notProperty(yield* build("daybreakBlue"), "cyberAccessProgram"); - assert.equal((yield* build(undefined, "daybreakBlue")).cyberAccessProgram, "daybreakBlue"); - assert.equal((yield* build("standard", "daybreakBlue")).cyberAccessProgram, "standard"); + const daybreak = { defaultProgram: "daybreakBlue" }; + assert.equal((yield* build(undefined, daybreak)).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("standard", daybreak)).cyberAccessProgram, "standard"); + // An unread config leaves no default, but an explicit Standard still goes. + const unknown = { defaultProgram: undefined }; + assert.notProperty(yield* build(undefined, unknown), "cyberAccessProgram"); + assert.equal((yield* build("standard", unknown)).cyberAccessProgram, "standard"); }), ); @@ -2519,7 +2524,7 @@ describe("CodexAdapterV2 post-settle continuation", () => { runtimePolicy: CODEX_TEST_RUNTIME_POLICY, modelSelection, hasT3Mcp: true, - defaultCyberAccessProgram: "daybreakBlue", + cyberAccess: { defaultProgram: "daybreakBlue" }, }); assert.equal(params.cyberAccessProgram, "standard"); assert.include( diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 2841c218d629..4ad6e33b2611 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -755,14 +755,14 @@ function codexRuntimeModeTurnDefaults(runtimeMode: RuntimeMode): { const isCodexCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); -function codexCatalogCyberAccessProgram( +function codexCyberAccess( models: ReadonlyArray, model: string, -): string | undefined { +): { readonly defaultProgram: string | undefined } | undefined { const descriptor = models .find((candidate) => candidate.slug === model) ?.capabilities?.optionDescriptors?.find((candidate) => candidate.id === "cyberAccessProgram"); - return descriptor?.type === "select" ? descriptor.currentValue : undefined; + return descriptor?.type === "select" ? { defaultProgram: descriptor.currentValue } : undefined; } export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTurnStartParams")( @@ -778,7 +778,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur readonly omitServiceTier?: boolean; readonly appContext?: ProviderAdapter.ProviderAdapterV2TurnInput["appContext"]; /** Set only for a model that offers Cyber Access; custom models never do. */ - readonly defaultCyberAccessProgram?: string | undefined; + readonly cyberAccess?: { readonly defaultProgram: string | undefined } | undefined; }) { const runtimeModeDefaults = codexRuntimeModeTurnDefaults(input.runtimePolicy.runtimeMode); const approvalPolicy = @@ -800,11 +800,11 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur : getCodexServiceTierOptionValue(input.modelSelection); // A chosen program goes as is, even one the model lacks, so Codex rejects it visibly. const cyberAccessProgram = - input.defaultCyberAccessProgram === undefined + input.cyberAccess === undefined ? undefined : [ getModelSelectionStringOptionValue(input.modelSelection, "cyberAccessProgram"), - input.defaultCyberAccessProgram, + input.cyberAccess.defaultProgram, ].find(isCodexCyberAccessProgram); // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( @@ -7110,10 +7110,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( const mcpSession = yield* mcpSessions.read(turnInput.threadId); const models = adapterOptions.models === undefined ? [] : yield* adapterOptions.models; const turnStartParams = yield* buildCodexTurnStartParams({ - defaultCyberAccessProgram: codexCatalogCyberAccessProgram( - models, - turnInput.modelSelection.model, - ), + cyberAccess: codexCyberAccess(models, turnInput.modelSelection.model), nativeThreadId: threadId, codexInput, runtimePolicy: turnInput.runtimePolicy, diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 789a7db018a5..4bb5b95434be 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -145,6 +145,7 @@ export interface CodexAppServerProviderSnapshot { readonly version: string | undefined; readonly models: ReadonlyArray | undefined; readonly serviceTier?: string | undefined; + /** Undefined when `config/read` failed, so T3 cannot know Codex's default program. */ readonly daybreakEnabled?: boolean | undefined; readonly cyberAccessProgramsUnavailable?: boolean | undefined; readonly skills: ReadonlyArray; @@ -423,11 +424,22 @@ function applyConfiguredCodexCyberAccess( "daybreakEnabled" | "cyberAccessProgramsUnavailable" >, ): ReadonlyArray { - // Without a descriptor no turn sends a program, leaving Codex's own config in charge. + // Without a descriptor no turn sends a program; without a default only an explicit choice goes. const offered = mapCodexOptionDescriptors(models, (descriptors, model) => - model.isCustom || snapshot.cyberAccessProgramsUnavailable === true - ? descriptors.filter((descriptor) => descriptor.id !== "cyberAccessProgram") - : descriptors, + descriptors.flatMap((descriptor): ReadonlyArray => { + if (descriptor.id !== "cyberAccessProgram" || descriptor.type !== "select") { + return [descriptor]; + } + if (model.isCustom || snapshot.cyberAccessProgramsUnavailable === true) return []; + if (snapshot.daybreakEnabled !== undefined) return [descriptor]; + const { currentValue: _currentValue, ...withoutDefault } = descriptor; + return [ + { + ...withoutDefault, + options: descriptor.options.map(({ isDefault: _isDefault, ...option }) => option), + }, + ]; + }), ); if (snapshot.daybreakEnabled !== true) return offered; return withConfiguredSelectDefault(offered, "cyberAccessProgram", (options) => @@ -753,12 +765,11 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun version, models, serviceTier: config?.service_tier ?? undefined, - daybreakEnabled: cyberAccess?.daybreak === true, - // An unread config hides `daybreak` and the API-key switch, so T3 must not pick a program. + daybreakEnabled: config === undefined ? undefined : cyberAccess?.daybreak === true, + // An unread config cannot rule out an API key with the programs turned off. cyberAccessProgramsUnavailable: - config === undefined || - (accountResponse.account?.type === "apiKey" && - cyberAccess?.features?.api_key_cyber_access_programs === false), + accountResponse.account?.type === "apiKey" && + (config === undefined || cyberAccess?.features?.api_key_cyber_access_programs === false), skills: skillsResponse ? parseCodexSkillsListResponse(skillsResponse, input.cwd) : [], } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/ProviderRegistry.test.ts b/apps/server/src/provider/ProviderRegistry.test.ts index 439c229fe502..1acec000a778 100644 --- a/apps/server/src/provider/ProviderRegistry.test.ts +++ b/apps/server/src/provider/ProviderRegistry.test.ts @@ -525,14 +525,16 @@ it.layer( ); return [ model.slug, - descriptor?.type === "select" ? descriptor.currentValue : null, + descriptor?.type === "select" + ? (descriptor.currentValue ?? "no default") + : null, ]; }), ), ), ); - assert.deepStrictEqual(yield* defaultPrograms({}), { + assert.deepStrictEqual(yield* defaultPrograms({ daybreakEnabled: false }), { both: "standard", "daybreak-only": "daybreakBlue", plain: null, @@ -544,6 +546,13 @@ it.layer( plain: null, "my-model": null, }); + // An unread config keeps the choice but cannot know Codex's default. + assert.deepStrictEqual(yield* defaultPrograms({}), { + both: "no default", + "daybreak-only": "no default", + plain: null, + "my-model": null, + }); assert.deepStrictEqual( yield* defaultPrograms({ account: { account: { type: "apiKey" }, requiresOpenaiAuth: false }, diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index c50e5b18c872..63f7f9ac95b1 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -103,8 +103,8 @@ for command and file approvals. ## Use Daybreak Models with cyber access programs show a Cyber Access option in the model -options. It starts on Standard, or on Daybreak when Codex's `config.toml` sets -`daybreak = true` or the model only runs under Daybreak. Your choice carries +options. It starts on Standard, or on Daybreak when `config.toml` in your Codex +home sets `daybreak = true` or the model only runs under Daybreak. Your choice carries over to new threads. Custom models do not offer it. If Codex rejects a turn because your login lacks the program, switch Cyber Access back to Standard or sign in to Codex with an account that has it. From fd321624ceca3982299980c690d0b7d55a934c43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:44:11 +0000 Subject: [PATCH 14/15] test(codex): leave the wire check to the replayed turn/start frame AI-assisted; verified with the tests and tools listed above. --- apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 47eb0895621e..82007f85f0fd 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -2527,7 +2527,6 @@ describe("CodexAdapterV2 post-settle continuation", () => { hasT3Mcp: true, cyberAccess: { defaultProgram: "daybreakBlue" }, }); - assert.equal(params.cyberAccessProgram, "standard"); assert.include( params.additionalContext?.t3_code_orchestration_01?.value ?? "", "delegate_task", From f09dec38234db8bc9a1ac57ff68f555da9dbe944 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 23:53:07 +0000 Subject: [PATCH 15/15] fix(codex): delegated tasks keep the parent's cyber access program Dropping it let the destination's Daybreak default silently replace an explicit Standard. The destination's Codex login now accepts or visibly rejects the inherited program. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/peer/RemoteDelegation.test.ts | 17 ++++++++++------- apps/server/src/peer/RemoteDelegation.ts | 3 +-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 59e02e6c1e26..1325cff62bba 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1170,21 +1170,24 @@ it.effect("a task named only by environment inherits the parent's model, within const a = yield* makeLaptop(database, offers); // The parent runs with full access; the link allows less. yield* linkTo(a.links, b, "auto-accept-edits"); - const reasoning = { id: "reasoningEffort", value: "high" }; - yield* seedParent(a, { + // An explicit Standard must survive, or a Daybreak default there would replace it. + const parentModelSelection = { ...modelSelection, - options: [reasoning, { id: "cyberAccessProgram", value: "daybreakBlue" }], - }); + options: [ + { id: "reasoningEffort", value: "high" }, + { id: "cyberAccessProgram", value: "standard" }, + ], + }; + yield* seedParent(a, parentModelSelection); const result = yield* a.call("delegate_task", { task: "Run the suite on the box.", target: { environmentId: box.environmentId }, }); expect(result.isError, JSON.stringify(result.content)).toBe(false); - // The parent's model and options, but not its cyber access program, which - // stays on this machine; and the link's modes rather than the parent's. + // The parent's model and options, and the link's modes rather than the parent's. expect(yield* Ref.get(thread.launchedWith)).toEqual({ - modelSelection: { ...modelSelection, options: [reasoning] }, + modelSelection: parentModelSelection, runtimeMode: "auto-accept-edits", interactionMode: "default", }); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index fa5fa745fd8a..cced5b0b4eb3 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -249,11 +249,10 @@ const make = Effect.gen(function* () { message: "Pass target.model from orchestrator_capabilities with this environmentId.", }); } - // Cyber access comes with the login on each machine, so it is never inherited across one. const options = target.options ?? (instanceId === inherited.instanceId && model === inherited.model - ? inherited.options?.filter((option) => option.id !== "cyberAccessProgram") + ? inherited.options : undefined); // Modes asked for are checked against this thread. Omitted ones are left // to the other side, which inherits this thread's modes capped by the