From 83ae20ff066a1039e4e0de49250d442d3e8ec3d9 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 20:53:09 +0100 Subject: [PATCH] fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same. --- .../server/src/server/agent/agent-manager.ts | 2 +- .../src/server/agent/option-controls.test.ts | 8 ++++ .../src/server/agent/option-controls.ts | 9 +++-- .../src/server/agent/provider-registry.ts | 18 +++++---- .../codex-app-server-agent.features.test.ts | 39 +++++++++++++++++++ .../src/server/daemon-config-store.test.ts | 6 +-- .../server/src/server/daemon-config-store.ts | 26 +++++++++++-- packages/server/src/server/session.ts | 16 ++++++-- .../server/src/server/websocket-server.ts | 4 +- 9 files changed, 107 insertions(+), 21 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 41b7ff784e..af776d7811 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5705,7 +5705,7 @@ export class AgentManager { * Options older data carries are converted for the adapter the provider runs on, before * defaults merge and before anything is stored; nothing past the manager sees the old keys. */ - private convertLegacyOptions( + convertLegacyOptions( provider: AgentProvider, options: T, ): T { diff --git a/packages/server/src/server/agent/option-controls.test.ts b/packages/server/src/server/agent/option-controls.test.ts index 8ea79912e8..50babb0e0a 100644 --- a/packages/server/src/server/agent/option-controls.test.ts +++ b/packages/server/src/server/agent/option-controls.test.ts @@ -1,6 +1,14 @@ import { describe, expect, test } from "vitest"; import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js"; +describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => { + test.each(["false", 1, {}])("%j", (value) => { + expect(() => + convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }), + ).toThrow(/fast_mode must be true, false or null/); + }); +}); + describe("convertLegacyOptions", () => { test.each([ { legacy: true, tier: "fast" }, diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 87b8d4253b..9b82b113cb 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -57,6 +57,9 @@ export function convertLegacyOptions( const { provider, options } = input; if (provider !== "codex" || !options || !("fast_mode" in options)) return options; const { fast_mode: legacy, ...rest } = options; + if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") { + throw new Error("Codex fast_mode must be true, false or null"); + } if ("service_tier" in rest || legacy === undefined) return rest; if (legacy === null) return { ...rest, service_tier: null }; return { ...rest, service_tier: legacy ? "fast" : "default" }; @@ -153,9 +156,9 @@ export function foldProfileFeatureValues(input: { }): Omit { const { profile, stored, source } = input; const { featureValues: legacyValues, ...rest } = profile; - // The app reads a profile's speed from its controls, so the old key is converted here for the - // provider id the profile names; a profile on a custom provider is converted when it is applied. - const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues }); + // Keys pass through unchanged: renaming an old key needs the adapter the provider runs on, which + // only the agent manager knows. + const featureValues = legacyValues; if (source === "stored") { return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; } diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 1bac569db5..ab8c9fe221 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -499,12 +499,12 @@ function wrapClientProvider( const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); - const providerOptions = convertLegacyOptions({ - provider: inner.provider, - options: configuredOptions, - }); + // Converted where it is used, so a malformed configured default fails the call that reads it + // rather than the registry that holds every provider. + const configuredDefaults = () => + convertLegacyOptions({ provider: inner.provider, options: configuredOptions }); const withDefaults = (own: ProviderOptions | undefined) => - mergeProviderOptions(providerOptions, own); + mergeProviderOptions(configuredDefaults(), own); return { provider, @@ -543,7 +543,10 @@ function wrapClientProvider( ), ), fetchCatalog: async (options, context) => { - const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context); + const catalog = await inner.fetchCatalog( + { ...options, providerOptions: configuredDefaults() }, + context, + ); return { ...catalog, models: mergeModels(provider, profileModels, additionalModels, catalog.models, { @@ -577,7 +580,8 @@ function wrapClientProvider( }) : undefined, listImportableSessions: listImportableSessions - ? async (options) => await listImportableSessions({ ...options, providerOptions }) + ? async (options) => + await listImportableSessions({ ...options, providerOptions: configuredDefaults() }) : undefined, importSession: importSession ? async (input, context) => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index c862e34f3d..e4bcca79e4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -7,6 +7,11 @@ import { describe, expect, test, vi } from "vitest"; import { AgentManager } from "../agent-manager.js"; import { toAgentPayload } from "../agent-projections.js"; import { AgentStorage } from "../agent-storage.js"; +import { + toWireDaemonConfig, + type ConvertProfileOptions, + type MutableDaemonConfig, +} from "../../daemon-config-store.js"; import { convertLegacyOptions } from "../option-controls.js"; import type { AgentClient, @@ -890,6 +895,40 @@ describe("Codex app-server provider controls", () => { }, ); + test.each(["codex", "my-codex"])( + "the config API shows a %s profile's legacy fast_mode as service_tier", + (providerId) => { + const { manager } = createLegacyFastHarness({}, providerId); + const stored: MutableDaemonConfig = { + agentProfiles: [ + { id: "p", name: "P", provider: providerId, options: { fast_mode: true } }, + ], + } as MutableDaemonConfig; + + const convert: ConvertProfileOptions = (provider, options) => + manager.convertLegacyOptions(provider, options); + + const wire = toWireDaemonConfig(stored, convert); + + expect(wire.agentProfiles?.[0]).toMatchObject({ + options: { service_tier: "fast" }, + featureValues: { service_tier: "fast" }, + }); + }, + ); + + test.each([ + ["a string", "false"], + ["a number", 1], + ])("a legacy fast_mode that is %s is rejected, not read as Fast", async (_name, value) => { + const { manager, create } = createLegacyFastHarness(); + const agentId = await create({}); + + await expect(manager.setAgentOptions(agentId, { fast_mode: value })).rejects.toThrow( + /fast_mode must be true, false or null/, + ); + }); + test("an older client turning fast_mode off selects Normal", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create({ service_tier: "priority" }); diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 7a7f567850..90b77c8070 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -239,7 +239,7 @@ describe("DaemonConfigStore", () => { expect(store.get().agentProfiles).toEqual([ { id: "p", name: "P", provider: "claude", options: { fast_mode: true } }, ]); - expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([ + expect(toWireDaemonConfig(store.get(), (_provider, options) => options).agentProfiles).toEqual([ { id: "p", name: "P", @@ -355,7 +355,7 @@ describe("DaemonConfigStore", () => { ]); }); - test("converts a Codex profile's legacy fast_mode to service_tier", async () => { + test("keeps a profile's legacy fast_mode key; the agent manager renames it", async () => { const store = storeWithProfile(); await store.patch({ @@ -369,7 +369,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" }, + options: { sandbox_mode: "read-only", plan_mode: false, fast_mode: true }, }, ]); }); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index f798e301f8..483e91d9a2 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -192,10 +192,30 @@ function foldConfigProfiles( }; } -/** The config API boundary: the unchanged app reads profile options from `featureValues`. */ -export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { +/** Renames old option keys for the adapter a provider runs on (the agent manager's conversion). */ +export type ConvertProfileOptions = ( + provider: string, + options: Record | undefined, +) => Record | undefined; + +/** + * The config API boundary: clients see each profile's options in their current keys, and the + * unchanged app reads them from `featureValues`. + */ +export function toWireDaemonConfig( + config: MutableDaemonConfig, + convertOptions: ConvertProfileOptions, +): MutableDaemonConfig { if (!config.agentProfiles) return config; - return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) }; + return { + ...config, + agentProfiles: config.agentProfiles.map((profile) => + withLegacyProfileFeatureValues({ + ...profile, + options: convertOptions(profile.provider, profile.options), + }), + ), + }; } function getValueAtPath(config: MutableDaemonConfig, path: string): unknown { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7c4f3405a2..46c28c065e 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -80,7 +80,11 @@ import { createWorkspaceScriptsService, type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; -import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js"; +import { + toWireDaemonConfig, + type DaemonConfigStore, + type MutableDaemonConfig, +} from "./daemon-config-store.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; @@ -2951,7 +2955,7 @@ export class Session { type: "get_daemon_config_response", payload: { requestId: msg.requestId, - config: toWireDaemonConfig(this.daemonConfigStore.get()), + config: this.toWireDaemonConfig(this.daemonConfigStore.get()), }, }); return undefined; @@ -2976,7 +2980,7 @@ export class Session { type: "set_daemon_config_response", payload: { requestId: msg.requestId, - config: toWireDaemonConfig(config), + config: this.toWireDaemonConfig(config), }, }); }); @@ -8623,6 +8627,12 @@ export class Session { return true; } + private toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { + return toWireDaemonConfig(config, (provider, options) => + this.agentManager.convertLegacyOptions(provider, options), + ); + } + private emit(msg: SessionOutboundMessage): void { if (!this.authorization.allowsOutbound(msg)) return; if (msg.type === "workspace_setup_progress") { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 2c3828353e..8f23cda3f4 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2201,7 +2201,9 @@ export class VoiceAssistantWebSocketServer { type: "status", payload: { status: "daemon_config_changed", - config: toWireDaemonConfig(config), + config: toWireDaemonConfig(config, (provider, options) => + this.agentManager.convertLegacyOptions(provider, options), + ), }, }); }