From dfb69718709988d7ef5ead275ed4776f60e587fb Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 11:48:34 +0200 Subject: [PATCH 1/8] feat(codex): request Daybreak cyber access per model Codex model discovery advertises per-model cyber access programs and turn/start accepts an experimental cyberAccessProgram, but T3 Code never exposed or sent it. Add a Cyber Access model option when Codex advertises a Daybreak program, and send the selection on turn/start. Automatic keeps today's behavior by omitting the field. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/CodexAdapterV2.test.ts | 24 ++++++++++++++++ .../Adapters/CodexAdapterV2.ts | 11 ++++++++ .../server/src/provider/CodexProvider.test.ts | 13 +++++++++ apps/server/src/provider/CodexProvider.ts | 28 +++++++++++++++++++ docs/user/composer.md | 3 ++ 5 files changed, 79 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 84c78b91453c2..c4c2c8271512f 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -255,6 +255,30 @@ describe("CodexAdapterV2 runtime policy", () => { assert.isDefined(withT3.additionalContext?.["t3_code_runtime"]); }), ); + + it.effect("requests a cyber access program only when one is selected", () => + Effect.gen(function* () { + const build = (cyberAccessProgram: string | undefined) => + CodexAdapterV2.buildCodexTurnStartParams({ + nativeThreadId: "native-cyber-access", + codexInput: [{ type: "text", text: "test" }], + runtimePolicy: { runtimeMode: "full-access", interactionMode: "default", cwd: null }, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + ...(cyberAccessProgram === undefined + ? {} + : { options: [{ id: "cyberAccessProgram", value: cyberAccessProgram }] }), + }, + }); + + assert.notProperty(yield* build(undefined), "cyberAccessProgram"); + assert.notProperty(yield* build("default"), "cyberAccessProgram"); + assert.notProperty(yield* build("daybreakPurple"), "cyberAccessProgram"); + assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); + assert.equal((yield* build("daybreakRed")).cyberAccessProgram, "daybreakRed"); + }), + ); }); describe("CodexAdapterV2 native protocol logging", () => { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 07c4e4e72eca7..1a7f9a77ec926 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -696,6 +696,7 @@ const decodeTurnSandboxPolicy = Schema.decodeUnknownEffect( const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( Schema.Union([CodexSchema.V2TurnStartParams__ReasoningEffort, Schema.Null]), ); +const isTurnCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ @@ -703,6 +704,8 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + // Experimental field the generated schema omits; Codex still enforces authorization. + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); type CodexTurnStartParamsWithCollaborationMode = @@ -786,6 +789,13 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.omitServiceTier === true ? undefined : getCodexServiceTierOptionValue(input.modelSelection); + const selectedCyberAccessProgram = getModelSelectionStringOptionValue( + input.modelSelection, + "cyberAccessProgram", + ); + const cyberAccessProgram = isTurnCyberAccessProgram(selectedCyberAccessProgram) + ? selectedCyberAccessProgram + : undefined; // MCP-written app context must be quoted user input, not developer instructions. const appContext = Object.fromEntries( (input.appContext ?? []).map((entry) => [ @@ -821,6 +831,7 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur ...(sandboxPolicy === undefined ? {} : { sandboxPolicy }), ...(effort === undefined ? {} : { effort }), ...(serviceTier === undefined ? {} : { serviceTier }), + ...(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 30f63b21f318e..ca5ef1324993c 100644 --- a/apps/server/src/provider/CodexProvider.test.ts +++ b/apps/server/src/provider/CodexProvider.test.ts @@ -12,6 +12,7 @@ it("maps current Codex model capability fields", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard", "daybreakBlue", "daybreakRed"] }, defaultServiceTier: "flex", serviceTiers: [ { @@ -61,6 +62,17 @@ it("maps current Codex model capability fields", () => { ], currentValue: "flex", }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "default", label: "Automatic", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + { id: "daybreakRed", label: "Daybreak Red" }, + ], + currentValue: "default", + }, ]); }); @@ -75,6 +87,7 @@ it("uses standard routing when the catalog has no default service tier", () => { id: "gpt-test", isDefault: true, model: "gpt-test", + availableAccessPrograms: { cyber: ["standard"] }, serviceTiers: [ { id: "priority", diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 2d546b36a9b15..1fba27a83ea63 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -113,6 +113,16 @@ const SERVICE_TIER_DESCRIPTIONS: Readonly> = { ultrafast: "Even faster, more expensive", }; +const DEFAULT_CYBER_ACCESS_PROGRAM_ID = "default"; + +const CYBER_ACCESS_PROGRAM_LABELS: Readonly< + Record +> = { + standard: "Standard", + daybreakBlue: "Daybreak Blue", + daybreakRed: "Daybreak Red", +}; + function reasoningEffortLabel(reasoningEffort: string): string { return REASONING_EFFORT_LABELS[reasoningEffort] ?? reasoningEffort; } @@ -241,6 +251,24 @@ export function mapCodexModelCapabilities( currentValue: defaultServiceTier, }); } + const cyberAccessPrograms = (model.availableAccessPrograms?.cyber ?? []).filter( + (program) => program !== "standard", + ); + if (cyberAccessPrograms.length > 0) { + optionDescriptors.push({ + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: DEFAULT_CYBER_ACCESS_PROGRAM_ID, label: "Automatic", isDefault: true }, + ...cyberAccessPrograms.map((program) => ({ + id: program, + label: CYBER_ACCESS_PROGRAM_LABELS[program], + })), + ], + currentValue: DEFAULT_CYBER_ACCESS_PROGRAM_ID, + }); + } return createModelCapabilities({ optionDescriptors, diff --git a/docs/user/composer.md b/docs/user/composer.md index c236da3c82c60..f7da825ae3b6b 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -124,6 +124,9 @@ returns to the remembered selection. Leaving reasoning level or service tier unset uses the provider's own configuration. +Codex shows **Cyber Access** only on models where your OpenAI account has a Daybreak program. +Choosing a program requests it for the turn; OpenAI still decides whether the account may use it. + ## Quote an assistant response On web and desktop, select text within one assistant response and choose From a1f5dee3ffd54a7e54c6ec1d297cd82753c34700 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:03:27 +0200 Subject: [PATCH 2/8] fix(codex): keep cyber access a user choice Address security review: delegate_task and thread creation can no longer pick a cyber access program other than the parent thread's, custom models do not borrow the Cyber Access option, the adapter forwards only Daybreak programs, choices carry descriptions, and the composer chip hides the Automatic default. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 34 ++++++++++++- apps/server/src/mcp/OrchestratorMcpService.ts | 2 + apps/server/src/mcp/delegatedTaskModes.ts | 20 ++++++++ .../Adapters/CodexAdapterV2.test.ts | 1 + .../Adapters/CodexAdapterV2.ts | 10 ++-- .../server/src/provider/CodexProvider.test.ts | 49 +++++++++++++++++-- apps/server/src/provider/CodexProvider.ts | 23 +++++++-- .../client-runtime/src/modelTraits.test.ts | 37 ++++++++++++++ packages/client-runtime/src/modelTraits.ts | 7 +++ 9 files changed, 169 insertions(+), 14 deletions(-) create mode 100644 packages/client-runtime/src/modelTraits.test.ts diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 0faf43acb9dd5..e4d3bc3cf6afa 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -66,7 +66,7 @@ import * as RemoteDelegation from "../peer/RemoteDelegation.ts"; import type { McpInvocationScope } from "./McpInvocationContext.ts"; import { idleThreadProjection, liveThreadShell } from "./McpToolAccess.testkit.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; -import { resolveRuntimeMode } from "./delegatedTaskModes.ts"; +import { resolveCyberAccessProgram, resolveRuntimeMode } from "./delegatedTaskModes.ts"; import type { ProjectionRecordFilter } from "../orchestration-v2/ProjectionStore.ts"; import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -3253,6 +3253,38 @@ describe("OrchestratorMcpService provider resolution", () => { }), ); }); + + describe("resolveCyberAccessProgram", () => { + const codex = (options?: ReadonlyArray<{ id: string; value: string }>) => ({ + instanceId: codexInstanceId, + model: "gpt-6-sol", + ...(options === undefined ? {} : { options }), + }); + const requested = (value: string) => [{ id: "cyberAccessProgram", value }]; + + it.effect("refuses a cyber access program the parent thread did not choose", () => + Effect.gen(function* () { + const error = yield* resolveCyberAccessProgram(codex(), requested("daybreakRed")).pipe( + Effect.flip, + ); + assert.equal(error.code, "invalid_request"); + const switched = yield* resolveCyberAccessProgram( + codex(requested("daybreakBlue")), + requested("daybreakRed"), + ).pipe(Effect.flip); + assert.equal(switched.code, "invalid_request"); + }), + ); + + it.effect("allows the parent's program, the default, or no request", () => + Effect.gen(function* () { + const parent = codex(requested("daybreakBlue")); + yield* resolveCyberAccessProgram(parent, requested("daybreakBlue")); + yield* resolveCyberAccessProgram(parent, requested("default")); + yield* resolveCyberAccessProgram(codex(), [{ id: "reasoningEffort", value: "high" }]); + }), + ); + }); }); describe("OrchestratorMcpService thread lookup", () => { diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index e53ed03a7c6fa..7d2d7c03baee4 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -118,6 +118,7 @@ import { import * as Metrics from "../observability/Metrics.ts"; import { interactionModeRank, + resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, runtimeModeRank, @@ -1443,6 +1444,7 @@ const make = Effect.gen(function* () { `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, ); } + yield* resolveCyberAccessProgram(inheritedSelection, requestedOptions); } const inheritsOptions = diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 54d25bbb36e02..3bf2b7c3b9678 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -5,8 +5,10 @@ import { type OrchestratorMcpInteractionMode, type OrchestratorMcpRuntimeMode, type ProviderInteractionMode, + type ProviderOptionSelection, type RuntimeMode, } from "@t3tools/contracts"; +import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import * as Effect from "effect/Effect"; /** How much a runtime mode allows, narrowest first. */ @@ -87,6 +89,24 @@ export function resolveInteractionMode( : Effect.succeed(resolved); } +/** A child may keep its parent's cyber access program but not choose another; only the user can. */ +export function resolveCyberAccessProgram( + parentSelection: ModelSelection, + requested: ReadonlyArray, +): Effect.Effect { + const value = requested.find((selection) => selection.id === "cyberAccessProgram")?.value; + const parentValue = + getModelSelectionStringOptionValue(parentSelection, "cyberAccessProgram") ?? "default"; + return value === undefined || value === "default" || value === parentValue + ? Effect.void + : Effect.fail( + new OrchestratorMcpFailure({ + code: "invalid_request", + message: `Cyber access program ${String(value)} differs from this thread's ${parentValue}. Omit it; only the user can choose one.`, + }), + ); +} + /** The task as its child receives it, with the role the caller asked for. */ export function taskPrompt(input: OrchestratorMcpDelegateTaskInput): string { return input.role === undefined || input.role === "general" diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index c4c2c8271512f..190ae5c3e4498 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -274,6 +274,7 @@ describe("CodexAdapterV2 runtime policy", () => { assert.notProperty(yield* build(undefined), "cyberAccessProgram"); assert.notProperty(yield* build("default"), "cyberAccessProgram"); + assert.notProperty(yield* build("standard"), "cyberAccessProgram"); assert.notProperty(yield* build("daybreakPurple"), "cyberAccessProgram"); assert.equal((yield* build("daybreakBlue")).cyberAccessProgram, "daybreakBlue"); assert.equal((yield* build("daybreakRed")).cyberAccessProgram, "daybreakRed"); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 1a7f9a77ec926..58549335bf73d 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -704,7 +704,7 @@ const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams. additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), - // Experimental field the generated schema omits; Codex still enforces authorization. + // Experimental: the generated struct lacks this field; Codex still enforces authorization. cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); @@ -793,9 +793,11 @@ export const buildCodexTurnStartParams = Effect.fn("CodexAdapterV2.buildCodexTur input.modelSelection, "cyberAccessProgram", ); - const cyberAccessProgram = isTurnCyberAccessProgram(selectedCyberAccessProgram) - ? selectedCyberAccessProgram - : undefined; + const cyberAccessProgram = + isTurnCyberAccessProgram(selectedCyberAccessProgram) && + selectedCyberAccessProgram !== "standard" + ? selectedCyberAccessProgram + : undefined; // 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.test.ts b/apps/server/src/provider/CodexProvider.test.ts index ca5ef1324993c..c4f002dfbe5b5 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({ @@ -67,9 +71,22 @@ it("maps current Codex model capability fields", () => { label: "Cyber Access", type: "select", options: [ - { id: "default", label: "Automatic", isDefault: true }, - { id: "daybreakBlue", label: "Daybreak Blue" }, - { id: "daybreakRed", label: "Daybreak Red" }, + { + id: "default", + label: "Automatic", + description: "Codex's default treatment", + isDefault: true, + }, + { + id: "daybreakBlue", + label: "Daybreak Blue", + description: "Request Daybreak Blue on each turn", + }, + { + id: "daybreakRed", + label: "Daybreak Red", + description: "Request Daybreak Red on each turn", + }, ], currentValue: "default", }, @@ -166,3 +183,27 @@ it("ignores custom models that shadow a preferred slug", () => { assert.deepStrictEqual(models.find((model) => model.isDefault)?.slug, "gpt-5.4"); }); + +it("does not lend cyber access to custom models", () => { + 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: [{ description: "Medium", reasoningEffort: "medium" }], + }); + const [, custom] = appendCustomCodexModels( + [{ slug: "gpt-test", name: "GPT Test", isCustom: false, capabilities }], + ["gpt-custom"], + ); + + assert.deepStrictEqual( + custom?.capabilities?.optionDescriptors?.map((descriptor) => descriptor.id), + ["reasoningEffort"], + ); +}); diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 1fba27a83ea63..e1442138f03d8 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -260,10 +260,16 @@ export function mapCodexModelCapabilities( label: "Cyber Access", type: "select", options: [ - { id: DEFAULT_CYBER_ACCESS_PROGRAM_ID, label: "Automatic", isDefault: true }, + { + id: DEFAULT_CYBER_ACCESS_PROGRAM_ID, + label: "Automatic", + description: "Codex's default treatment", + isDefault: true, + }, ...cyberAccessPrograms.map((program) => ({ id: program, label: CYBER_ACCESS_PROGRAM_LABELS[program], + description: `Request ${CYBER_ACCESS_PROGRAM_LABELS[program]} on each turn`, })), ], currentValue: DEFAULT_CYBER_ACCESS_PROGRAM_ID, @@ -341,14 +347,21 @@ 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. + * the first built-in's descriptors, except cyber access, which Codex grants + * per catalog model; an entry with its own capabilities keeps them. */ -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; + const fallbackCapabilities = borrowed + ? createModelCapabilities({ + optionDescriptors: (borrowed.optionDescriptors ?? []).filter( + (descriptor) => descriptor.id !== "cyberAccessProgram", + ), + }) + : null; return providerModelsFromSettings(models, customModels, fallbackCapabilities); } diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts new file mode 100644 index 0000000000000..92e2a5c4ba6c4 --- /dev/null +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -0,0 +1,37 @@ +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { buildTraitsTriggerDisplay } from "./modelTraits.ts"; + +describe("buildTraitsTriggerDisplay", () => { + it("labels Codex cyber access only when a program is chosen", () => { + const display = (currentValue: string) => + buildTraitsTriggerDisplay({ + provider: ProviderDriverKind.make("codex"), + primarySelectDescriptorId: "reasoningEffort", + ultrathinkPromptControlled: false, + descriptors: [ + { + id: "reasoningEffort", + label: "Reasoning", + type: "select", + options: [{ id: "medium", label: "Medium", isDefault: true }], + currentValue: "medium", + }, + { + id: "cyberAccessProgram", + label: "Cyber Access", + type: "select", + options: [ + { id: "default", label: "Automatic", isDefault: true }, + { id: "daybreakBlue", label: "Daybreak Blue" }, + ], + currentValue, + }, + ] satisfies ReadonlyArray, + }).label; + + expect(display("default")).toBe("Medium"); + expect(display("daybreakBlue")).toBe("Medium · Daybreak Blue"); + }); +}); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index b05f9226defb3..1b6c9222cb599 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -155,6 +155,13 @@ export function buildTraitsTriggerDisplay(input: { continue; } } + if ( + input.provider === "codex" && + descriptor.id === "cyberAccessProgram" && + getProviderOptionCurrentValue(descriptor) === "default" + ) { + continue; + } const label = input.ultrathinkPromptControlled && descriptor.id === input.primarySelectDescriptorId ? "Ultrathink" From f40c39cac01278fb67840f7e424f10c164506411 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:29:39 +0200 Subject: [PATCH 3/8] fix(codex): send cyber access on the wire and guard every agent path The Codex client encoded turn/start with its own schema, which dropped cyberAccessProgram before it reached Codex. The adapter now decodes with the client's exported schema, so its test covers what is sent. Remote delegation, t3_thread_launch and t3_thread_configure now apply the same cyber access check as local delegation. The composer chip also reads the reported selection before hiding the Automatic label, and the docs describe what the option's presence means. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/delegatedTaskModes.ts | 2 +- apps/server/src/mcp/toolkits/core.test.ts | 14 ++++++++++++++ .../src/mcp/toolkits/project/handlers.test.ts | 12 ++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 7 ++++++- .../src/mcp/toolkits/thread/handlers.ts | 2 ++ .../Adapters/CodexAdapterV2.ts | 15 +++------------ apps/server/src/peer/RemoteDelegation.test.ts | 12 ++++++++++++ apps/server/src/peer/RemoteDelegation.ts | 2 ++ docs/user/composer.md | 2 +- .../client-runtime/src/modelTraits.test.ts | 19 +++++++++++++++++-- packages/client-runtime/src/modelTraits.ts | 6 +++++- .../effect-codex-app-server/src/client.ts | 5 ++++- 12 files changed, 79 insertions(+), 19 deletions(-) diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 3bf2b7c3b9678..a60c63edf0f0c 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -91,7 +91,7 @@ export function resolveInteractionMode( /** A child may keep its parent's cyber access program but not choose another; only the user can. */ export function resolveCyberAccessProgram( - parentSelection: ModelSelection, + parentSelection: ModelSelection | undefined, requested: ReadonlyArray, ): Effect.Effect { const value = requested.find((selection) => selection.id === "cyberAccessProgram")?.value; diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 602031290763d..f253d7a112769 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -525,6 +525,20 @@ it.effect("a client caller targets any thread within its ceiling and cannot act code: "runtime_mode_escalation_denied", }); + const cyber = yield* call( + "t3_thread_configure", + { + threadId: "other-project-thread", + modelSelection: { + instanceId: "codex", + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }, + clientScope("auto"), + ); + expect(declaredFailure(cyber)).toMatchObject({ code: "invalid_request" }); + const forked = yield* call( "t3_thread_transfer", { action: "fork", sourcePoint: { type: "latest_stable" } }, diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 6721e89478550..279029d01bda8 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -188,6 +188,7 @@ it.effect( readonly targeted?: false; readonly overrideModel?: ModelSelection; readonly hostedAppUrl?: string; + readonly modelSelection?: ModelSelection; readonly refusedByCeiling?: true; }, ) { @@ -248,6 +249,17 @@ it.effect( expect(escalated.result).toMatchObject({ code: "runtime_mode_escalation_denied" }); expect(escalated.launched).toHaveLength(0); + const cyber = yield* launchWith("full-access", "full-access", { + title: "Fix", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }); + expect(cyber.result).toMatchObject({ code: "invalid_request" }); + expect(cyber.launched).toHaveLength(0); + const untargeted = yield* launchWith("full-access", "full-access", { title: "Fix", targeted: false, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index a14ee03daa470..9fb8bec4d900e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -26,7 +26,7 @@ import * as ProviderRegistry from "../../../provider/ProviderRegistry.ts"; import * as DateTime from "effect/DateTime"; import { resolveNewThreadModelSelection } from "@t3tools/shared/usageLimits"; import { resolveProjectSettings } from "@t3tools/shared/projectSettings"; -import { resolveRuntimeMode } from "../../delegatedTaskModes.ts"; +import { resolveCyberAccessProgram, resolveRuntimeMode } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { newCommandId, readCaller, resolveProjectId, unavailable } from "../../threadAccess.ts"; @@ -208,6 +208,11 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); + if (!replaying && input.modelSelection !== undefined) + yield* resolveCyberAccessProgram( + caller?.modelSelection, + input.modelSelection.options ?? [], + ); let startedRuntimeMode = projectDefaults !== undefined && input.runtimeMode === undefined ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index f968312437518..fa9f8343524a4 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -10,6 +10,7 @@ import { import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { resolveCyberAccessProgram } from "../../delegatedTaskModes.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -218,6 +219,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { threads, projection: { thread }, } = yield* readThread(input.threadId); + yield* resolveCyberAccessProgram(thread.modelSelection, modelSelection.options ?? []); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 58549335bf73d..3d73ba2e65dc5 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -698,20 +698,11 @@ const decodeTurnReasoningEffort = Schema.decodeUnknownEffect( ); const isTurnCyberAccessProgram = Schema.is(CodexSchema.V2TurnStartParams__CyberAccessProgram); -const CodexTurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( - Schema.fieldsAssign({ - collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), - additionalContext: Schema.optionalKey( - Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), - ), - // Experimental: the generated struct lacks this field; Codex still enforces authorization. - cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), - }), -); type CodexTurnStartParamsWithCollaborationMode = - typeof CodexTurnStartParamsWithCollaborationMode.Type; + typeof CodexClient.V2TurnStartParamsWithCollaborationMode.Type; +// The client encodes with this schema, so decoding with it keeps only what reaches Codex. const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( - CodexTurnStartParamsWithCollaborationMode, + CodexClient.V2TurnStartParamsWithCollaborationMode, ); const isProviderAdapterRuntimeRequestResponseError = Schema.is( ProviderAdapter.ProviderAdapterRuntimeRequestResponseError, diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 00b7b5d6f6963..2320af73fbc26 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1111,6 +1111,18 @@ it.effect("a task named only by environment inherits the parent's model, within clientRequestId: "broader", }); expect(broader.isError).toBe(true); + + // Only the user picks a cyber access program; the parent here has none. + const cyber = yield* a.call("delegate_task", { + task: "Run the suite on the box.", + target: { + environmentId: box.environmentId, + options: { cyberAccessProgram: "daybreakRed" }, + }, + clientRequestId: "cyber", + }); + expect(cyber.isError).toBe(true); + expect(JSON.stringify(cyber.content)).toContain("Cyber access program daybreakRed"); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index 89917026ff8f7..b74e2e076cd6f 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -28,6 +28,7 @@ import type * as Tool from "effect/ai/Tool"; import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import { + resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, taskPrompt, @@ -231,6 +232,7 @@ const make = Effect.gen(function* () { message: "Pass target.model from orchestrator_capabilities with this environmentId.", }); } + if (target.options !== undefined) yield* resolveCyberAccessProgram(inherited, target.options); const options = target.options ?? (instanceId === inherited.instanceId && model === inherited.model diff --git a/docs/user/composer.md b/docs/user/composer.md index f7da825ae3b6b..f16945da47701 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -124,7 +124,7 @@ returns to the remembered selection. Leaving reasoning level or service tier unset uses the provider's own configuration. -Codex shows **Cyber Access** only on models where your OpenAI account has a Daybreak program. +Codex shows **Cyber Access** on models whose catalog lists a Daybreak program for your account. Choosing a program requests it for the turn; OpenAI still decides whether the account may use it. ## Quote an assistant response diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts index 92e2a5c4ba6c4..2d0508368d059 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,15 +1,29 @@ -import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; +import { + ProviderDriverKind, + ProviderInstanceId, + type ProviderOptionDescriptor, +} from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildTraitsTriggerDisplay } from "./modelTraits.ts"; describe("buildTraitsTriggerDisplay", () => { it("labels Codex cyber access only when a program is chosen", () => { - const display = (currentValue: string) => + const display = (currentValue: string, reportedProgram?: string) => buildTraitsTriggerDisplay({ provider: ProviderDriverKind.make("codex"), primarySelectDescriptorId: "reasoningEffort", ultrathinkPromptControlled: false, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-sol" }, + ...(reportedProgram === undefined + ? {} + : { + reportedModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: reportedProgram }], + }, + }), descriptors: [ { id: "reasoningEffort", @@ -33,5 +47,6 @@ describe("buildTraitsTriggerDisplay", () => { expect(display("default")).toBe("Medium"); expect(display("daybreakBlue")).toBe("Medium · Daybreak Blue"); + expect(display("default", "daybreakBlue")).toBe("Medium · Daybreak Blue"); }); }); diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 1b6c9222cb599..17963cd035bff 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -158,7 +158,11 @@ export function buildTraitsTriggerDisplay(input: { if ( input.provider === "codex" && descriptor.id === "cyberAccessProgram" && - getProviderOptionCurrentValue(descriptor) === "default" + getProviderOptionCurrentValue( + descriptor, + input.modelSelection, + input.reportedModelSelection, + ) === "default" ) { continue; } diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 28f677e03ee0a..058162fa51fad 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -86,12 +86,15 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( +/** `turn/start` params as sent, with experimental fields the generated struct lacks. */ +export const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), additionalContext: Schema.optionalKey( Schema.Record(Schema.String, CodexSchema.V2TurnStartParams__AdditionalContextEntry), ), + // Codex still enforces authorization for the requested program. + cyberAccessProgram: Schema.optionalKey(CodexSchema.V2TurnStartParams__CyberAccessProgram), }), ); From 18781c67a9b5fe87fbaebb061211b3363f2fe514 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:43:18 +0200 Subject: [PATCH 4/8] fix(codex): keep cyber access off project defaults and other machines Address the second security review: t3_project_update and t3_project_create can no longer set a cyber access program as a project's default model, and a task delegated to another environment never carries the parent's program, which belongs to this machine's account. The refusal now says where the user chooses a program. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/delegatedTaskModes.ts | 2 +- .../src/mcp/toolkits/project/handlers.test.ts | 25 +++++++++++ .../src/mcp/toolkits/project/handlers.ts | 9 ++++ apps/server/src/peer/RemoteDelegation.test.ts | 42 +++++++++++++++++-- apps/server/src/peer/RemoteDelegation.ts | 18 ++++++-- .../effect-codex-app-server/src/client.ts | 5 ++- 6 files changed, 92 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index a60c63edf0f0c..2101a0e9b8331 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -102,7 +102,7 @@ export function resolveCyberAccessProgram( : Effect.fail( new OrchestratorMcpFailure({ code: "invalid_request", - message: `Cyber access program ${String(value)} differs from this thread's ${parentValue}. Omit it; only the user can choose one.`, + message: `Cyber access program ${String(value)} differs from this thread's ${parentValue}. Omit it; the user chooses one in the composer's model options.`, }), ); } diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 279029d01bda8..8a50160abe82e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -288,6 +288,31 @@ it.effect( }), ); +it.effect("a client cannot make a cyber access program a project's default", () => + Effect.gen(function* () { + const { projectId, dependencies } = clientLaunchHarness({ + runtimeModeCeiling: "full-access", + launched: [], + }); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + const result = yield* toolkit + .handle("t3_project_update", { + projectId, + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(result.at(-1)?.result).toMatchObject({ code: "invalid_request" }); + }), +); + it.effect("a launch steps an inherited mode down to the provider's, and refuses one it lacks", () => Effect.gen(function* () { // Pi offers no Auto; a mode a provider lacks would run as Supervised. diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 9fb8bec4d900e..665d80deceba3 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -350,6 +350,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ...(created.commitError === undefined ? {} : { commitError: created.commitError }), }; } + yield* resolveCyberAccessProgram(undefined, input.defaultModelSelection?.options ?? []); const commandId = yield* newCommandId(); return yield* projects .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) @@ -362,6 +363,14 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const projects = yield* Project.ProjectService; + const requested = input.defaultModelSelection?.options; + if (requested !== undefined) { + const current = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); + yield* resolveCyberAccessProgram( + Option.getOrUndefined(current)?.defaultModelSelection ?? undefined, + requested, + ); + } return yield* projects .update({ ...input, commandId: yield* newCommandId() }) .pipe(Effect.mapError(projectFailure)); diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index 2320af73fbc26..5b9e39f856671 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, @@ -642,7 +643,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"); @@ -654,7 +658,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, @@ -1122,7 +1126,39 @@ it.effect("a task named only by environment inherits the parent's model, within clientRequestId: "cyber", }); expect(cyber.isError).toBe(true); - expect(JSON.stringify(cyber.content)).toContain("Cyber access program daybreakRed"); + expect(JSON.stringify(cyber.content)).toContain("stays on this machine"); + }), + ).pipe(Effect.provide(NodeHttpServer.layerTest)), +); + +it.effect("a task there inherits the parent's model without its cyber access program", () => + Effect.scoped( + Effect.gen(function* () { + const thread = yield* makeBoxThread; + const b = yield* serveBox(thread); + const offers = yield* SubscriptionRef.make< + ReadonlyArray + >([]); + const database = yield* SqlitePersistence.layerMemory.pipe(Layer.build); + const a = yield* makeLaptop(database, offers); + yield* linkTo(a.links, b, "full-access"); + yield* seedParent(a, { + ...modelSelection, + options: [ + { id: "reasoningEffort", value: "high" }, + { 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); + expect((yield* Ref.get(thread.launchedWith))?.modelSelection).toEqual({ + ...modelSelection, + options: [{ id: "reasoningEffort", value: "high" }], + }); }), ).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/peer/RemoteDelegation.ts b/apps/server/src/peer/RemoteDelegation.ts index b74e2e076cd6f..edca6b47a1a6a 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -28,7 +28,6 @@ import type * as Tool from "effect/ai/Tool"; import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import { - resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, taskPrompt, @@ -232,12 +231,23 @@ const make = Effect.gen(function* () { message: "Pass target.model from orchestrator_capabilities with this environmentId.", }); } - if (target.options !== undefined) yield* resolveCyberAccessProgram(inherited, target.options); - const options = + // A cyber access program belongs to this machine's account; a task there runs with Automatic. + if ( + target.options?.some( + (selection) => selection.id === "cyberAccessProgram" && selection.value !== "default", + ) + ) + return yield* new OrchestratorMcpFailure({ + code: "invalid_request", + message: + "A cyber access program stays on this machine. Omit cyberAccessProgram for a task in another environment.", + }); + const options = ( target.options ?? (instanceId === inherited.instanceId && model === inherited.model ? inherited.options - : undefined); + : undefined) + )?.filter((selection) => selection.id !== "cyberAccessProgram"); // 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 // link's access, so a narrower link still takes the task. diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index 058162fa51fad..98e71bd266bb8 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -86,7 +86,10 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -/** `turn/start` params as sent, with experimental fields the generated struct lacks. */ +/** + * `turn/start` params as sent, with experimental fields the generated struct lacks. + * Encoding drops unknown keys, so callers build params with this schema too. + */ export const V2TurnStartParamsWithCollaborationMode = CodexSchema.V2TurnStartParams.pipe( Schema.fieldsAssign({ collaborationMode: Schema.optionalKey(CodexSchema.ClientRequest__CollaborationMode), From 1a44fc3ea1c2b82296f05bc7b8e1bb4e678afdad Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 13:07:44 +0200 Subject: [PATCH 5/8] fix(codex): check every cyber access entry and test the wire encoding An agent could store [default, daybreakBlue] through t3_thread_configure; the guard read only the first entry, and a later option merge kept the second. The guard now refuses any entry other than the parent's program. A client-level test covers that turn/start encoding keeps the field. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/OrchestratorMcpService.test.ts | 5 +++++ apps/server/src/mcp/delegatedTaskModes.ts | 12 +++++++++--- packages/effect-codex-app-server/src/client.test.ts | 10 ++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index e4d3bc3cf6afa..f6acaf7fe9b87 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3273,6 +3273,11 @@ describe("OrchestratorMcpService provider resolution", () => { requested("daybreakRed"), ).pipe(Effect.flip); assert.equal(switched.code, "invalid_request"); + const duplicated = yield* resolveCyberAccessProgram(codex(), [ + ...requested("default"), + ...requested("daybreakBlue"), + ]).pipe(Effect.flip); + assert.equal(duplicated.code, "invalid_request"); }), ); diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 2101a0e9b8331..7cecc04135e0b 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -94,15 +94,21 @@ export function resolveCyberAccessProgram( parentSelection: ModelSelection | undefined, requested: ReadonlyArray, ): Effect.Effect { - const value = requested.find((selection) => selection.id === "cyberAccessProgram")?.value; const parentValue = getModelSelectionStringOptionValue(parentSelection, "cyberAccessProgram") ?? "default"; - return value === undefined || value === "default" || value === parentValue + // Check every entry: a later duplicate would win wherever options are merged. + const chosen = requested.find( + (selection) => + selection.id === "cyberAccessProgram" && + selection.value !== "default" && + selection.value !== parentValue, + ); + return chosen === undefined ? Effect.void : Effect.fail( new OrchestratorMcpFailure({ code: "invalid_request", - message: `Cyber access program ${String(value)} differs from this thread's ${parentValue}. Omit it; the user chooses one in the composer's model options.`, + message: `Cyber access program ${String(chosen.value)} differs from this thread's ${parentValue}. Omit it; the user chooses one in the composer's model options.`, }), ); } diff --git a/packages/effect-codex-app-server/src/client.test.ts b/packages/effect-codex-app-server/src/client.test.ts index 56d9dbcebfae1..2df4356968e16 100644 --- a/packages/effect-codex-app-server/src/client.test.ts +++ b/packages/effect-codex-app-server/src/client.test.ts @@ -7,6 +7,7 @@ import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -22,6 +23,15 @@ const mockPeerPath = Effect.map(Effect.service(Path.Path), (path) => ); const mockPeerArgs = (path: string) => [path]; +it("keeps a requested cyber access program when encoding turn/start", () => { + const encode = Schema.encodeUnknownSync(CodexClient.V2TurnStartParamsWithCollaborationMode); + const params = { threadId: "thread-1", input: [] }; + for (const program of ["daybreakBlue", "daybreakRed"] as const) { + assert.equal(encode({ ...params, cyberAccessProgram: program }).cyberAccessProgram, program); + } + assert.notProperty(encode(params), "cyberAccessProgram"); +}); + it.layer(NodeServices.layer)("effect-codex-app-server client", (it) => { const makeHandle = (env?: Record) => Effect.gen(function* () { From aebd4d5c2ace0b86b84c28f0099ee9f7c3a7e887 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 13:30:19 +0200 Subject: [PATCH 6/8] fix(codex): check cyber access on replayed thread launches A retried t3_thread_launch skipped the cyber access check, yet its model selection still reaches a new run when the retry adds the first message. The check now runs whenever a launch names a model selection. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 17 +++++++++++++++++ .../server/src/mcp/toolkits/project/handlers.ts | 9 +++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 8a50160abe82e..2921b0b8be20e 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -404,6 +404,23 @@ it.effect("a launch retried with the same clientRequestId replays the first one" expect(ids[2]).not.toEqual(ids[0]); // Without a key every launch is new. expect(ids[3]).not.toEqual(ids[4]); + + // A retry still cannot choose a cyber access program. + const retried = yield* toolkit + .handle("t3_thread_launch", { + title: "Fix", + projectId, + message: "Fix the bug", + clientRequestId: "fix-1", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, + }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + expect(retried.at(-1)?.result).toMatchObject({ code: "invalid_request" }); + expect(launched).toHaveLength(5); }), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 665d80deceba3..6905080b3ddbc 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -131,9 +131,9 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { Effect.mapError(unavailable), Effect.map(Option.getOrUndefined), ); - // A retry of an accepted launch replays it: it keeps the model it started - // with and skips the checks only a new launch needs, which the project - // may no longer pass, such as a base branch deleted since. + // A retry of an accepted launch replays it: without a modelSelection it keeps + // the launched model, and it skips the checks only a new launch needs, which + // the project may no longer pass, such as a base branch deleted since. const launched = input.clientRequestId === undefined ? null @@ -208,7 +208,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { message: "Pass modelSelection: the project has no default model. orchestrator_capabilities lists providers and models.", }); - if (!replaying && input.modelSelection !== undefined) + // A retry's selection still reaches a new run, so replays are checked too. + if (input.modelSelection !== undefined) yield* resolveCyberAccessProgram( caller?.modelSelection, input.modelSelection.options ?? [], From ca99227d49bbb3f30ad0e7ebd52590444ed13649 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 14:20:04 +0200 Subject: [PATCH 7/8] fix(codex): carry a cyber access program only to the same model Codex grants programs per account and model, so a program kept through t3_thread_configure, a launch, a delegation or a project default could be sent to a model that does not offer it. A program now carries over only on the same provider instance and model; any other request is refused. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/OrchestratorMcpService.test.ts | 35 +++++++++++++------ apps/server/src/mcp/OrchestratorMcpService.ts | 6 +++- apps/server/src/mcp/delegatedTaskModes.ts | 20 ++++++++--- apps/server/src/mcp/toolkits/core.test.ts | 35 ++++++++++++------- .../src/mcp/toolkits/project/handlers.ts | 12 +++---- .../src/mcp/toolkits/thread/handlers.ts | 2 +- 6 files changed, 73 insertions(+), 37 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index f6acaf7fe9b87..b91a62f157031 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3264,29 +3264,42 @@ describe("OrchestratorMcpService provider resolution", () => { it.effect("refuses a cyber access program the parent thread did not choose", () => Effect.gen(function* () { - const error = yield* resolveCyberAccessProgram(codex(), requested("daybreakRed")).pipe( - Effect.flip, - ); + const error = yield* resolveCyberAccessProgram( + codex(), + codex(requested("daybreakRed")), + ).pipe(Effect.flip); assert.equal(error.code, "invalid_request"); const switched = yield* resolveCyberAccessProgram( codex(requested("daybreakBlue")), - requested("daybreakRed"), + codex(requested("daybreakRed")), ).pipe(Effect.flip); assert.equal(switched.code, "invalid_request"); - const duplicated = yield* resolveCyberAccessProgram(codex(), [ - ...requested("default"), - ...requested("daybreakBlue"), - ]).pipe(Effect.flip); + const duplicated = yield* resolveCyberAccessProgram( + codex(), + codex([...requested("default"), ...requested("daybreakBlue")]), + ).pipe(Effect.flip); assert.equal(duplicated.code, "invalid_request"); + // The model may not offer the parent's program, so it does not carry over. + const otherModel = yield* resolveCyberAccessProgram(codex(requested("daybreakBlue")), { + ...codex(requested("daybreakBlue")), + model: "gpt-6.1-sol", + }).pipe(Effect.flip); + assert.equal(otherModel.code, "invalid_request"); }), ); it.effect("allows the parent's program, the default, or no request", () => Effect.gen(function* () { const parent = codex(requested("daybreakBlue")); - yield* resolveCyberAccessProgram(parent, requested("daybreakBlue")); - yield* resolveCyberAccessProgram(parent, requested("default")); - yield* resolveCyberAccessProgram(codex(), [{ id: "reasoningEffort", value: "high" }]); + yield* resolveCyberAccessProgram(parent, codex(requested("daybreakBlue"))); + yield* resolveCyberAccessProgram(parent, { + ...codex(requested("default")), + model: "gpt-6.1-sol", + }); + yield* resolveCyberAccessProgram( + codex(), + codex([{ id: "reasoningEffort", value: "high" }]), + ); }), ); }); diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index 7d2d7c03baee4..409b38d98c9b8 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1444,7 +1444,11 @@ const make = Effect.gen(function* () { `Model ${model} on provider ${instanceId} rejected options: ${invalid.join(" ")}`, ); } - yield* resolveCyberAccessProgram(inheritedSelection, requestedOptions); + yield* resolveCyberAccessProgram(inheritedSelection, { + instanceId, + model, + options: requestedOptions, + }); } const inheritsOptions = diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 7cecc04135e0b..21ca8605b288c 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -89,15 +89,25 @@ export function resolveInteractionMode( : Effect.succeed(resolved); } -/** A child may keep its parent's cyber access program but not choose another; only the user can. */ +/** + * A child may keep its parent's cyber access program on the same instance and model, but not + * choose another; only the user can. Codex grants programs per account and model. + */ export function resolveCyberAccessProgram( parentSelection: ModelSelection | undefined, - requested: ReadonlyArray, + requested: Pick & { + readonly options?: ReadonlyArray | undefined; + }, ): Effect.Effect { + const sameModel = + parentSelection?.instanceId === requested.instanceId && + parentSelection.model === requested.model; const parentValue = - getModelSelectionStringOptionValue(parentSelection, "cyberAccessProgram") ?? "default"; + (sameModel + ? getModelSelectionStringOptionValue(parentSelection, "cyberAccessProgram") + : undefined) ?? "default"; // Check every entry: a later duplicate would win wherever options are merged. - const chosen = requested.find( + const chosen = (requested.options ?? []).find( (selection) => selection.id === "cyberAccessProgram" && selection.value !== "default" && @@ -108,7 +118,7 @@ export function resolveCyberAccessProgram( : Effect.fail( new OrchestratorMcpFailure({ code: "invalid_request", - message: `Cyber access program ${String(chosen.value)} differs from this thread's ${parentValue}. Omit it; the user chooses one in the composer's model options.`, + message: `Cyber access program ${String(chosen.value)} was requested for ${requested.model}, but this thread's program for that model is ${parentValue}. Omit it; the user chooses one in the composer's model options.`, }), ); } diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index f253d7a112769..5308a9f361308 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -525,19 +525,24 @@ it.effect("a client caller targets any thread within its ceiling and cannot act code: "runtime_mode_escalation_denied", }); - const cyber = yield* call( - "t3_thread_configure", - { - threadId: "other-project-thread", - modelSelection: { - instanceId: "codex", - model: "gpt-6-sol", - options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + // The thread runs Daybreak Blue on gpt-6-sol: configure may keep it, not move it. + const configureCyber = (model: string) => + call( + "t3_thread_configure", + { + threadId: "other-project-thread", + modelSelection: { + instanceId: "codex", + model, + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, }, - }, - clientScope("auto"), - ); - expect(declaredFailure(cyber)).toMatchObject({ code: "invalid_request" }); + clientScope("auto"), + ); + expect((yield* configureCyber("gpt-6-sol")).isError).toBe(false); + expect(declaredFailure(yield* configureCyber("gpt-6.1-sol"))).toMatchObject({ + code: "invalid_request", + }); const forked = yield* call( "t3_thread_transfer", @@ -559,6 +564,12 @@ it.effect("a client caller targets any thread within its ceiling and cannot act thread: { id: ThreadId.make("other-project-thread"), projectId: "other-project", + providerInstanceId: "codex", + modelSelection: { + instanceId: "codex", + model: "gpt-6-sol", + options: [{ id: "cyberAccessProgram", value: "daybreakBlue" }], + }, runtimeMode: "auto", interactionMode: "default", deletedAt: null, diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 6905080b3ddbc..0b2efa7d8b686 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -210,10 +210,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { }); // A retry's selection still reaches a new run, so replays are checked too. if (input.modelSelection !== undefined) - yield* resolveCyberAccessProgram( - caller?.modelSelection, - input.modelSelection.options ?? [], - ); + yield* resolveCyberAccessProgram(caller?.modelSelection, input.modelSelection); let startedRuntimeMode = projectDefaults !== undefined && input.runtimeMode === undefined ? yield* resolveRuntimeMode(runtimeMode, projectDefaults.defaultRuntimeMode).pipe( @@ -351,7 +348,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { ...(created.commitError === undefined ? {} : { commitError: created.commitError }), }; } - yield* resolveCyberAccessProgram(undefined, input.defaultModelSelection?.options ?? []); + if (input.defaultModelSelection) + yield* resolveCyberAccessProgram(undefined, input.defaultModelSelection); const commandId = yield* newCommandId(); return yield* projects .create({ ...input, workspaceRoot, commandId, projectId: ProjectId.make(commandId) }) @@ -364,8 +362,8 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_update: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const projects = yield* Project.ProjectService; - const requested = input.defaultModelSelection?.options; - if (requested !== undefined) { + const requested = input.defaultModelSelection; + if (requested) { const current = yield* projects.getById(input.projectId).pipe(Effect.mapError(unavailable)); yield* resolveCyberAccessProgram( Option.getOrUndefined(current)?.defaultModelSelection ?? undefined, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index fa9f8343524a4..fe79e96e787be 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -219,7 +219,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { threads, projection: { thread }, } = yield* readThread(input.threadId); - yield* resolveCyberAccessProgram(thread.modelSelection, modelSelection.options ?? []); + yield* resolveCyberAccessProgram(thread.modelSelection, modelSelection); const type = modelSelectionCommandType(thread.providerInstanceId, modelSelection); const result = yield* threads .dispatch({ type, threadId: thread.id, commandId: yield* newCommandId(), modelSelection }) From e7b6ebbd373192cb60a6b8ec7c4dc104c93a524f Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 15:01:06 +0200 Subject: [PATCH 8/8] fix(codex): keep caller values out of the cyber access refusal The refusal message echoed the requested program and model, which callers control and the schema does not bound. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/delegatedTaskModes.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/server/src/mcp/delegatedTaskModes.ts b/apps/server/src/mcp/delegatedTaskModes.ts index 21ca8605b288c..81adbf74c6837 100644 --- a/apps/server/src/mcp/delegatedTaskModes.ts +++ b/apps/server/src/mcp/delegatedTaskModes.ts @@ -118,7 +118,8 @@ export function resolveCyberAccessProgram( : Effect.fail( new OrchestratorMcpFailure({ code: "invalid_request", - message: `Cyber access program ${String(chosen.value)} was requested for ${requested.model}, but this thread's program for that model is ${parentValue}. Omit it; the user chooses one in the composer's model options.`, + message: + "The requested cyber access program is not valid for this thread. Omit it; the user chooses one in the composer's model options.", }), ); }