From c2677a269011437722f52c4f27f37b555d88a877 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 16:03:05 +0100 Subject: [PATCH] fix(server): a live options patch is rejected when the session cannot apply it A session with no applyOptions (Pi) now rejects a patch instead of storing a value that fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch option change while its first query is starting, after the options have been copied. --- docs/providers.md | 6 ++- .../src/server/agent/agent-manager.test.ts | 32 ++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 13 ++++++- .../agent/providers/claude/agent.test.ts | 38 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 15 +++++++- .../codex-app-server-agent.features.test.ts | 12 ++++-- .../agent/providers/codex-app-server-agent.ts | 9 ++++- 7 files changed, 116 insertions(+), 9 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 9e8f7c0ce2..2c954657b7 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -48,8 +48,10 @@ or without a control. reject. A key the running session cannot apply (one the provider reads only when it opens) is rejected too, so a stored option always describes what runs. Claude reads most options when it starts its query, and replacing a started query would end its background work, so after the - first prompt only Fast can change. It - returns the options it ended up with when they differ from the request. The daemon + first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread + starts, so it applies only `service_tier`, `plan_mode` and `fast_mode` live. A session with no + `applyOptions` rejects every patch; the daemon enforces this. The provider returns the options + it ended up with when they differ from the request. The daemon stores the patch and that result in one step after the call resolves, onto the agent's options as they are at that moment, so the last write wins. Throwing rejects the whole patch and none of it is stored. Emit nothing from inside the call. If the provider's own state moved diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index b0f6d42cf4..3278cbb631 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12950,6 +12950,38 @@ describe("agent options", () => { expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: false }); }); + test("a provider whose session cannot apply options rejects a patch and stores nothing", async () => { + // Like a provider with no live option support: the session has no `applyOptions`. + class NoOptionsSession extends TestAgentSession { + constructor(config: AgentSessionConfig) { + super(config); + Object.defineProperty(this, "applyOptions", { value: undefined }); + } + } + class NoOptionsClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new NoOptionsSession(config); + } + } + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-options-")); + const manager = new AgentManager({ + clients: { codex: new NoOptionsClient() }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + }); + const agent = await manager.createAgent( + { provider: "codex", cwd: workdir, providerOptions: { fast_mode: false } }, + undefined, + { workspaceId: undefined }, + ); + + await expect(manager.setAgentOptions(agent.id, { fast_mode: true })).rejects.toThrow( + /fast_mode.*cannot change on a running session/, + ); + + expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ fast_mode: false }); + }); + test("agent.create.before hook featureValues fold into providerOptions", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-")); const storage = new AgentStorage(join(workdir, "agents"), logger); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 0a2677a00b..6995fc5a06 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1,4 +1,5 @@ import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js"; +import { ProviderOptionsValidationError } from "./provider-options.js"; import type { AgentToolResult } from "@getpaseo/plugin/server"; import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; import { ensureAgentLoaded } from "./agent-loading.js"; @@ -2283,7 +2284,17 @@ export class AgentManager { async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise { const agent = this.requireAgent(agentId); let ended: ProviderOptionsPatch = {}; - if (agent.session.applyOptions && Object.keys(patch).length > 0) { + if (Object.keys(patch).length > 0) { + // A provider that cannot apply options to a running agent must not store them either. + if (!agent.session.applyOptions) { + throw new ProviderOptionsValidationError( + agent.provider, + Object.keys(patch).map((key) => ({ + path: [key], + message: "cannot change on a running session; it applies when the agent is opened", + })), + ); + } const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null)); const resolved = this.effectiveOptionsFor(agent.provider, set); ended = diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 6699662803..7bdd13fd49 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1507,6 +1507,44 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("rejects a change to a launch option while the first query is starting", async () => { + const { queryFactory } = createQueryMock(); + let releaseBinary!: () => void; + const binaryLookup = new Promise((resolve) => { + releaseBinary = resolve; + }); + let lookupStarted!: () => void; + const lookupEntered = new Promise((resolve) => { + lookupStarted = resolve; + }); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => { + lookupStarted(); + await binaryLookup; + return "/test/claude/bin"; + }, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + providerOptions: { disallowedTools: ["Bash"] }, + }); + + const turn = session.startTurn("hello"); + await lookupEntered; + await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow( + /disallowedTools.*cannot change on a running session/, + ); + releaseBinary(); + await turn; + + expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash"] }); + await session.close(); + }); + test("a patch before the first prompt keeps the restrictions the session opened with", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 10cf11db47..aadd6545b9 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2378,6 +2378,8 @@ class ClaudeAgentSession implements AgentSession { private compactionMarkerOpen = false; private queryPumpPromise: Promise | null = null; private queryRestartNeeded = false; + /** True from the moment a query's launch options are copied until the query exists. */ + private queryStarting = false; private pendingInterruptAbort = false; /** * Whether Claude's main session is inside one of its own turns: set by system/init and by a @@ -2811,7 +2813,7 @@ class ClaudeAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { // Every option but Fast is read when the query is built, and replacing a started query would // end its background work, so once it exists only Fast can change. - if (this.query) { + if (this.query || this.queryStarting) { assertChangesAreLive({ provider: "claude", patch: options, @@ -3531,7 +3533,15 @@ class ClaudeAgentSession implements AgentSession { this.persistence = null; const input = createAsyncMessageInput(); - const options = await this.buildOptions(launchMode); + // buildOptions copies the launch options before it waits, so from here they are fixed. + this.queryStarting = true; + let options: ClaudeOptions; + try { + options = await this.buildOptions(launchMode); + } catch (error) { + this.queryStarting = false; + throw error; + } this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.runtimeSafety.reset(); @@ -3549,6 +3559,7 @@ class ClaudeAgentSession implements AgentSession { }, }, ); + this.queryStarting = false; const fastMode = this.resolveFastModeSetting(); if (fastMode !== null) { await this.query.applyFlagSettings({ fastMode }); 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 23b4ee6684..6b39b7fde2 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 @@ -535,10 +535,16 @@ describe("Codex app-server provider controls", () => { }); }); - test("applyOptions accepts an option that has no control", async () => { - const { session } = await createConnectedSession(); + test("rejects a live change to the approval policy or sandbox, which the thread fixed when it started", async () => { + const { session } = await createConnectedSession({ + providerOptions: { sandbox_mode: "workspace-write" }, + }); - await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).resolves.toEqual({}); + await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow( + /sandbox_mode.*cannot change on a running session/, + ); + // The same value is not a change. + await expect(session.applyOptions?.({ sandbox_mode: "workspace-write" })).resolves.toEqual({}); }); test("startTurn includes serviceTier when fast mode is enabled", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5085be66de..dbe67f4fbc 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,6 +1,6 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import { applyProviderOptionsPatch } from "../option-controls.js"; -import { validateProviderOptions } from "../provider-options.js"; +import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, type AgentPermissionAction, @@ -4698,6 +4698,13 @@ export class CodexAppServerAgentSession implements AgentSession { /** Applies only the options the patch sets; the rest of the running options stay as they are. */ async applyOptions(options: ProviderOptions): Promise> { + // The approval policy and sandbox are sent when the thread starts, not with each turn. + assertChangesAreLive({ + provider: "codex", + patch: options, + current: this.providerOptions, + live: ["service_tier", "fast_mode", "plan_mode"], + }); const { fast_mode: _legacyFast, ...running } = this.providerOptions; const valid = validateProviderOptions(