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.
This commit is contained in:
Mohamed Boudra
2026-11-04 04:25:08 +01:00
parent 0dfa5a41b2
commit cebf992617
7 changed files with 118 additions and 11 deletions
@@ -13175,6 +13175,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<AgentSession> {
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);
@@ -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";
@@ -2301,7 +2302,17 @@ export class AgentManager {
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
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 =
@@ -1697,6 +1697,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<void>((resolve) => {
releaseBinary = resolve;
});
let lookupStarted!: () => void;
const lookupEntered = new Promise<void>((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({
@@ -2378,6 +2378,8 @@ class ClaudeAgentSession implements AgentSession {
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | 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
@@ -2818,7 +2820,7 @@ class ClaudeAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<void> {
// 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,
@@ -3540,9 +3542,17 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
// 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);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
} catch (error) {
this.queryStarting = false;
throw error;
}
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
@@ -3560,6 +3570,7 @@ class ClaudeAgentSession implements AgentSession {
},
},
);
this.queryStarting = false;
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) {
await this.query.applyFlagSettings({ fastMode });
@@ -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 () => {
@@ -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,
@@ -4754,6 +4754,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<Record<string, unknown>> {
// 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(