From 390059a1bdc93bad35d3cd652afdb96f958e8c7a Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 11:48:34 +0200 Subject: [PATCH 1/9] 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 55a1e66ee725..213e3ce523b5 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 997547cdb330..25d0490a723b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -701,6 +701,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({ @@ -708,6 +709,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 = @@ -791,6 +794,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) => [ @@ -826,6 +836,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 30f63b21f318..ca5ef1324993 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 2d546b36a9b1..1fba27a83ea6 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 32de36e13b84..a16b6fb42e43 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -126,6 +126,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 fdad8a3fe79a249ca39c3fb778ca3002fa8bee49 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:03:27 +0200 Subject: [PATCH 2/9] 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 | 33 ++++++++++++- packages/client-runtime/src/modelTraits.ts | 7 +++ 9 files changed, 164 insertions(+), 15 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index df13d037005a..7940516dadf6 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -67,7 +67,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"; @@ -3376,6 +3376,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 a1425d56e8e5..f288b430bf77 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -120,6 +120,7 @@ import { import * as Metrics from "../observability/Metrics.ts"; import { interactionModeRank, + resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, runtimeModeRank, @@ -1460,6 +1461,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 54d25bbb36e0..3bf2b7c3b967 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 213e3ce523b5..34140ae71c1d 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 25d0490a723b..03ffe168309a 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -709,7 +709,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), }), ); @@ -798,9 +798,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 ca5ef1324993..c4f002dfbe5b 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 1fba27a83ea6..e1442138f03d 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 index 24da035532a0..aca223aef106 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,4 +1,4 @@ -import type { ProviderOptionDescriptor } from "@t3tools/contracts"; +import { ProviderDriverKind, type ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; @@ -32,4 +32,35 @@ describe("buildTraitsTriggerDisplay", () => { ).toBe(input.label); expect(getDescriptorStringValue(descriptor)).toBe(input.value); }); + + 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 6e84acf10fc0..36ef048f5124 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 abaec40679693f29e4edda32c45679d174fa3a9d Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:29:39 +0200 Subject: [PATCH 3/9] 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 3bf2b7c3b967..a60c63edf0f0 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 fd07107b08bc..b7b989ebf446 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -547,6 +547,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 b77df3ac2075..563ce93fec27 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 ab27b6b5bc4b..69da638ef508 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"; @@ -210,6 +210,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 32e45b5d0a1c..7bb47b05c734 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 03ffe168309a..4dfe4c1ddd9c 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -703,20 +703,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 cb46d63ab623..888a03d64c6f 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -1122,6 +1122,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 1222f029eec5..ee6cb9942a56 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -30,6 +30,7 @@ import { withExecutedModelNote } from "@t3tools/provider-core/server/subagentPro import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import { + resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, taskPrompt, @@ -233,6 +234,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 a16b6fb42e43..6dbd36628b19 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -126,7 +126,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 aca223aef106..d8467373b21e 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,4 +1,8 @@ -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, getDescriptorStringValue } from "./modelTraits.ts"; @@ -34,11 +38,21 @@ 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", @@ -62,5 +76,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 36ef048f5124..7fe0bf88cf2a 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 28f677e03ee0..058162fa51fa 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 19821acbd53a65816fd50564dd45531ad848b1f3 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 12:43:18 +0200 Subject: [PATCH 4/9] 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 a60c63edf0f0..2101a0e9b833 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 563ce93fec27..f562bbbf83a1 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 69da638ef508..ed688ae90f85 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -352,6 +352,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) }) @@ -364,6 +365,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 888a03d64c6f..03f0c243906a 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, @@ -1133,7 +1137,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 ee6cb9942a56..bd8dc356fd20 100644 --- a/apps/server/src/peer/RemoteDelegation.ts +++ b/apps/server/src/peer/RemoteDelegation.ts @@ -30,7 +30,6 @@ import { withExecutedModelNote } from "@t3tools/provider-core/server/subagentPro import type * as McpInvocationContext from "../mcp/McpInvocationContext.ts"; import { ProjectToolkit } from "../mcp/toolkits/project/tools.ts"; import { - resolveCyberAccessProgram, resolveInteractionMode, resolveRuntimeMode, taskPrompt, @@ -234,12 +233,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 058162fa51fa..98e71bd266bb 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 ec19fd6668051a554bfd69a9395495728b002c8d Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 13:07:44 +0200 Subject: [PATCH 5/9] 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 7940516dadf6..c586be7b9b67 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3396,6 +3396,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 2101a0e9b833..7cecc04135e0 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 56d9dbcebfae..2df4356968e1 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 fe00fd7c8423b17b9f5c2bff9b048b3ef3842f5a Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 13:30:19 +0200 Subject: [PATCH 6/9] 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 f562bbbf83a1..cdb1cd1fa6a5 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 ed688ae90f85..fa3f56942d84 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -133,9 +133,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 @@ -210,7 +210,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 cfe6f810388de3ea537a10344050027937a028a9 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 14:20:04 +0200 Subject: [PATCH 7/9] 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 c586be7b9b67..c6400ee3434a 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -3387,29 +3387,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 f288b430bf77..0cee3a4d0b1b 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1461,7 +1461,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 7cecc04135e0..21ca8605b288 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 b7b989ebf446..615044cd726b 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -547,19 +547,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", @@ -581,6 +586,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 fa3f56942d84..dd9b79e0ea5c 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -212,10 +212,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( @@ -353,7 +350,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) }) @@ -366,8 +364,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 7bb47b05c734..3663d125b321 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 b9ab32d04c00b8bf3597877069fcab028a203218 Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Fri, 30 Oct 2026 15:01:06 +0200 Subject: [PATCH 8/9] 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 21ca8605b288..81adbf74c683 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.", }), ); } From 6a12378a80fbdf07ec4aefa0c6ee144f6795840c Mon Sep 17 00:00:00 2001 From: Kyryl R Date: Tue, 10 Nov 2026 08:46:00 +0200 Subject: [PATCH 9/9] fix(codex): label cyber access from its descriptor alone The trait display input no longer carries a provider (#33177). Only the Codex provider emits the cyberAccessProgram descriptor, so the descriptor id is enough to keep the default program out of the trigger label. Co-Authored-By: Claude Opus 5.5 --- packages/client-runtime/src/modelTraits.test.ts | 7 +------ packages/client-runtime/src/modelTraits.ts | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/client-runtime/src/modelTraits.test.ts b/packages/client-runtime/src/modelTraits.test.ts index d8467373b21e..150be9d829cb 100644 --- a/packages/client-runtime/src/modelTraits.test.ts +++ b/packages/client-runtime/src/modelTraits.test.ts @@ -1,8 +1,4 @@ -import { - ProviderDriverKind, - ProviderInstanceId, - type ProviderOptionDescriptor, -} from "@t3tools/contracts"; +import { ProviderInstanceId, type ProviderOptionDescriptor } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { buildTraitsTriggerDisplay, getDescriptorStringValue } from "./modelTraits.ts"; @@ -40,7 +36,6 @@ describe("buildTraitsTriggerDisplay", () => { it("labels Codex cyber access only when a program is chosen", () => { const display = (currentValue: string, reportedProgram?: string) => buildTraitsTriggerDisplay({ - provider: ProviderDriverKind.make("codex"), primarySelectDescriptorId: "reasoningEffort", ultrathinkPromptControlled: false, modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-6-sol" }, diff --git a/packages/client-runtime/src/modelTraits.ts b/packages/client-runtime/src/modelTraits.ts index 7fe0bf88cf2a..7b1e76734a73 100644 --- a/packages/client-runtime/src/modelTraits.ts +++ b/packages/client-runtime/src/modelTraits.ts @@ -156,7 +156,6 @@ export function buildTraitsTriggerDisplay(input: { } } if ( - input.provider === "codex" && descriptor.id === "cyberAccessProgram" && getProviderOptionCurrentValue( descriptor,