fix(server): resolve supplied options against configured defaults before applying them live

setAgentOptions hands a running provider the keys the agent holds, each resolved
against the config.json defaults, so a nested patch (for example a sandbox
object) keeps the configured restrictions. Removed keys stay absent.
This commit is contained in:
Mohamed Boudra
2026-11-01 13:21:42 +01:00
parent d5953d2ea7
commit dbe5d7b3e1
2 changed files with 43 additions and 3 deletions
@@ -1,3 +1,4 @@
import { mergeProviderOptions } from "./provider-options.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import {
prepareAgentMessage,
@@ -12880,6 +12881,37 @@ describe("agent options", () => {
]);
});
test("a supplied option is resolved against the configured defaults before it reaches the provider", async () => {
class DefaultsClient extends OptionsClient {
override effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
return (
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {}
);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new DefaultsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.setAgentOptions(created.id, { sandbox: { network: { allowed: ["a.example"] } } });
expect(client.session?.applied).toEqual([
{
sandbox: { enabled: true, failIfUnavailable: true, network: { allowed: ["a.example"] } },
},
]);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
sandbox: { network: { allowed: ["a.example"] } },
});
});
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent();
@@ -2290,9 +2290,7 @@ export class AgentManager {
// a rejection leaves the record, its snapshots and its stored copy as they were.
this.pendingOptionCandidates.set(agentId, candidate);
try {
// Only the agent's own options: a removed key is absent, so the running provider keeps
// its setting. `config.json` defaults apply when the session is next opened.
await agent.session.applyOptions(candidate);
await agent.session.applyOptions(this.liveOptionsFor(agent.provider, candidate));
} finally {
// Events the provider queued before answering must reach the candidate, not the record.
await this.drainSessionEvents(agentId);
@@ -2306,6 +2304,16 @@ export class AgentManager {
});
}
/**
* The options a running provider is handed: the keys the agent holds, each resolved against
* the `config.json` defaults. A removed key is absent, so the provider keeps its setting;
* defaults for absent keys apply when the session is next opened.
*/
private liveOptionsFor(provider: AgentProvider, own: ProviderOptions): ProviderOptions {
const effective = this.effectiveOptionsFor(provider, own);
return Object.fromEntries(Object.keys(own).map((key) => [key, effective[key]]));
}
private effectiveOptionsFor(
provider: AgentProvider,
own: ProviderOptions | undefined,