From 2dd83e43cbf7a253e5dfd38ddadc107854a2135d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 15:10:35 +0100 Subject: [PATCH] fix(server): apply an old app's profile featureValues over the profile's stored options The unchanged app's profile form only knows the controls, so a name-only edit dropped options it never shows, such as sandbox_mode. Its featureValues now apply as a patch over the options already stored for the profile (null removes a key), and a write with no featureValues keeps them. --- .../src/server/agent/option-controls.ts | 23 +++--- .../src/server/daemon-config-store.test.ts | 73 +++++++++++++++++++ .../server/src/server/daemon-config-store.ts | 11 ++- 3 files changed, 96 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index a5982c4f86..220a3d051a 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -93,23 +93,28 @@ interface ProfileOptionFields { /** * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - * Profiles saved before options carry `featureValues`. When the profile comes from storage, - * `options` wins over a stale `featureValues` key. When it comes from a client write, the - * client that sent `featureValues` is the unchanged app, which only knows that field, so its - * `featureValues` win over the `options` it received from the previous read. + * Profiles saved before options carry `featureValues`; when one comes from storage, `options` + * wins over a stale `featureValues` key. + * + * A profile written by the unchanged app is a patch: its form only knows the controls, so its + * `featureValues` are applied over the options already stored for that profile (`null` + * deletes), and options the form never shows, such as `sandbox_mode`, survive an edit. */ export function foldProfileFeatureValues( profile: T, + stored: Record | undefined, source: "stored" | "client write" = "stored", ): Omit { const { featureValues, ...rest } = profile; - if (!featureValues) return rest; + if (source === "stored") { + return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; + } + // The form sends no `featureValues` when no control is set, which is still an edit of a + // profile that may hold other options. + if (!featureValues && (rest.options || !stored)) return rest; return { ...rest, - options: - source === "stored" - ? { ...featureValues, ...rest.options } - : { ...rest.options, ...featureValues }, + options: applyProviderOptionsPatch(stored ?? rest.options, featureValues ?? {}), }; } diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 812b1d13d2..02d09f93eb 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -250,6 +250,79 @@ describe("DaemonConfigStore", () => { ]); }); + describe("a profile edited by the unchanged app", () => { + function storeWithProfile(): DaemonConfigStore { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + return new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + agentProfiles: [ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "read-only", fast_mode: false }, + }, + ], + }); + } + + test("keeps the options its form never shows when it changes a control", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "Renamed", + provider: "codex", + options: { sandbox_mode: "read-only", fast_mode: true }, + }, + ]); + }); + + test("keeps every option when it sends no featureValues at all", async () => { + const store = storeWithProfile(); + + await store.patch({ agentProfiles: [{ id: "p", name: "Renamed", provider: "codex" }] }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "Renamed", + provider: "codex", + options: { sandbox_mode: "read-only", fast_mode: false }, + }, + ]); + }); + + test("removes an option whose featureValues entry is null", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { id: "p", name: "P", provider: "codex", options: { sandbox_mode: "read-only" } }, + ]); + }); + }); + test("a profile stored with featureValues loads as options", () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index 83ff80d498..9dfe7c4fcc 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -177,11 +177,18 @@ function omitProvidersFromOverrides( function foldConfigProfiles( config: T, source: "stored" | "client write", + held: AgentProfile[] = [], ): T { if (!config.agentProfiles) return config; return { ...config, - agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)), + agentProfiles: config.agentProfiles.map((profile) => + foldProfileFeatureValues( + profile, + held.find((candidate) => candidate.id === profile.id)?.options, + source, + ), + ), }; } @@ -442,7 +449,7 @@ export class DaemonConfigStore { ); } const { removeProviders = [], ...submittedPatch } = parsedPatch; - const patch = foldConfigProfiles(submittedPatch, "client write"); + const patch = foldConfigProfiles(submittedPatch, "client write", this.current.agentProfiles); const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch);