fix(server): address Phase 1 audit findings on option controls

- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
This commit is contained in:
Mohamed Boudra
2026-11-01 07:53:29 +01:00
parent bc00edf904
commit 6c22bc72df
22 changed files with 569 additions and 60 deletions
@@ -2429,6 +2429,18 @@ test("listDraftControls uses client feature listing without a model", async () =
providerOptions: { auto_accept: true },
},
]);
// Options without a control come back too.
await expect(
manager.listDraftControls({
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
}),
).resolves.toEqual({
controls: [draftControl],
options: { auto_accept: true, sandbox_mode: "workspace-write" },
});
});
test("listDraftControls uses explicit model config without default model fetching", async () => {
@@ -12741,8 +12753,8 @@ describe("agent options", () => {
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true });
const payload = toAgentPayload(agent!);
expect(payload.options).toEqual({ service_tier: "default", fast_mode: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
});
test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => {
@@ -12772,6 +12784,54 @@ describe("agent options", () => {
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
});
test("shows the option as the session resolved it while the stored intent stays", async () => {
class ResolvingSession extends OptionsSession {
displayOptions(options: ProviderOptions): ProviderOptions {
return options.service_tier === "fast" ? { ...options, service_tier: "priority" } : options;
}
}
class ResolvingClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ResolvingSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const manager = new AgentManager({
clients: { codex: new ResolvingClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } },
undefined,
{ workspaceId: undefined },
);
const agent = manager.getAgent(created.id);
expect(agent?.config.providerOptions).toEqual({ service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "priority" });
});
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent();
await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
expect(client.session?.applied).toEqual([
{ service_tier: "default", sandbox_mode: "read-only" },
]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" });
expect(toAgentPayload(agent!).options).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
await manager.flush();
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
sandbox_mode: "read-only",
});
});
test("a provider that rejects an option leaves the stored options unchanged", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false });
client.session!.rejectWith = new Error("fast mode unavailable");
@@ -12807,4 +12867,75 @@ describe("agent options", () => {
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ fast_mode: true });
expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues");
});
test("agent.create.before hook featureValues edit overrides an option the create already set", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
// A plugin written against the previous SDK: flips one value, leaves the rest.
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: {
...request.config,
featureValues: { ...request.config.featureValues, fast_mode: true },
},
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
fast_mode: true,
plan_mode: true,
});
});
test("agent.create.before hook leaving featureValues untouched keeps providerOptions edits", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: {
...request.config,
providerOptions: { ...request.config.providerOptions, plan_mode: false },
},
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
fast_mode: false,
plan_mode: false,
});
});
});
@@ -63,7 +63,8 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js";
import {
applyProviderOptionsPatch,
foldFeatureValues,
exposeFeatureValuesToHook,
foldHookFeatureValues,
type ProviderOptionsPatch,
} from "./option-controls.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
@@ -1270,7 +1271,10 @@ export class AgentManager {
const session = await client.createSession(normalizedConfig);
try {
return { controls: session.controls ?? [], options };
return {
controls: session.controls ?? [],
options: session.displayOptions?.(options) ?? options,
};
} finally {
try {
await session.close();
@@ -1369,13 +1373,14 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) {
const sent = exposeFeatureValuesToHook(config);
const request = await this.pluginLifecycle.before("agent.create.before", {
config,
config: sent,
env: options.env,
});
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
// against the previous SDK edits `config.featureValues`.
config = { ...foldFeatureValues(request.config), internal: config.internal };
config = { ...foldHookFeatureValues(request.config, sent), internal: config.internal };
options = { ...options, env: request.env };
}
await this.deleteAgentState(resolvedAgentId);
@@ -5245,7 +5250,11 @@ export class AgentManager {
if ("session" in agent && agent.session?.controls) {
agent.controls = agent.session.controls;
}
agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
const effective = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
agent.effectiveOptions =
"session" in agent && agent.session?.displayOptions
? agent.session.displayOptions(effective)
: effective;
}
private checkAndSetAttention(agent: ManagedAgent): void {
@@ -479,16 +479,16 @@ describe("toAgentPayload", () => {
},
];
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
// Options without a control (sandbox paths, tool lists) never reach the snapshot.
// Options without a control are part of the snapshot too.
const agent = createManagedAgent({
controls,
effectiveOptions: { fast_mode: true, sandbox_workspace_write: { writable_roots: ["/x"] } },
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
});
const payload = toAgentPayload(agent);
expect(payload.controls).toEqual(controls);
expect(payload.options).toEqual({ fast_mode: true });
expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" });
expect(payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
@@ -148,7 +148,7 @@ export function toAgentPayload(
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls(agent.controls, agent.effectiveOptions),
...(agent.effectiveOptions
? { options: controlledOptions(agent.effectiveOptions, agent.controls) }
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
: {}),
controls: normalizeControls(agent.controls),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
@@ -463,20 +463,9 @@ function normalizeControls(
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
}
// Snapshots expose only the options a control describes. Other provider options (sandbox paths,
// allowed tools, hub-supplied settings) stay daemon-side.
function controlledOptions(
options: ProviderOptions,
controls: AgentOptionControl[] | null | undefined,
): ProviderOptions {
const exposed: ProviderOptions = {};
for (const control of controls ?? []) {
const value = sanitizeOptionalJson(options[control.key]);
if (value !== undefined) {
exposed[control.key] = value;
}
}
return exposed;
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
const sanitized = sanitizeOptionalJson(options);
return sanitized && isJsonObject(sanitized) ? sanitized : {};
}
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
@@ -722,6 +722,12 @@ export interface AgentSession {
* the daemon stores the patch only when this resolves.
*/
applyOptions?(options: ProviderOptions): Promise<void>;
/**
* The effective options as this session honors them, for display. The daemon keeps the
* user's stored intent; a value the provider resolves differently (a renamed tier, an
* unavailable choice) is shown as the provider resolved it.
*/
displayOptions?(options: ProviderOptions): ProviderOptions;
revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
@@ -5867,7 +5867,7 @@ describe("agent profile listing MCP tool", () => {
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
options: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
@@ -5882,7 +5882,7 @@ describe("agent profile listing MCP tool", () => {
const response = await tool.handler({});
// Profiles saved before options carry `featureValues`; the tool reports them as `options`.
// The config store holds profiles with `options` only; the tool reports them as stored.
expect(response.structuredContent).toEqual({
profiles: [
{
@@ -1,3 +1,4 @@
import { isDeepStrictEqual } from "node:util";
import type {
AgentFeature,
AgentOptionControl,
@@ -52,14 +53,64 @@ export function foldFeatureValues<
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Profiles saved before options carry `featureValues`; readers see `options`.
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
* hook sees the config's options there. Keys the hook changed come back as options and
* override what the config already held; keys it left alone do not.
*/
export function foldProfileFeatureValues<
T extends { options?: Record<string, unknown>; featureValues?: Record<string, unknown> },
>(profile: T): Omit<T, "featureValues"> {
export function exposeFeatureValuesToHook<
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
>(config: T): T {
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
return { ...config, featureValues: { ...config.providerOptions } };
}
export function foldHookFeatureValues<
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
>(returned: T, sent: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = returned;
if (!featureValues) return rest;
const before = sent.featureValues ?? {};
const edits: Record<string, unknown> = {};
for (const [key, value] of Object.entries(featureValues)) {
if (!isDeepStrictEqual(value, before[key])) edits[key] = value;
}
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions, ...edits } };
}
interface ProfileOptionFields {
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
/**
* 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.
*/
export function foldProfileFeatureValues<T extends ProfileOptionFields>(
profile: T,
source: "stored" | "client write" = "stored",
): Omit<T, "featureValues"> {
const { featureValues, ...rest } = profile;
if (!featureValues) return rest;
return { ...rest, options: { ...featureValues, ...rest.options } };
return {
...rest,
options:
source === "stored"
? { ...featureValues, ...rest.options }
: { ...rest.options, ...featureValues },
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
* both fields.
*/
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
}
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
@@ -474,6 +474,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
applyOptions: inner.applyOptions?.bind(inner),
displayOptions: inner.displayOptions?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner),
@@ -1601,6 +1601,29 @@ describe("ACPAgentSession Zed parity", () => {
});
});
test("stops auto-accepting permissions once auto_accept is removed from the options", async () => {
const session = createSessionWithConfig({ provider: "generic-acp" });
const internals = asInternals<ACPSessionInternals>(session);
internals.sessionId = "session-1";
internals.connection = {};
await session.applyOptions({ auto_accept: true });
await session.applyOptions({});
const permission = session.requestPermission({
sessionId: "session-1",
toolCall: {
toolCallId: "tool-1",
title: "Run command",
kind: "execute",
status: "pending",
},
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
} satisfies RequestPermissionRequest);
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
void permission;
});
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
const events: Array<{ type: string; request?: { id: string } }> = [];
@@ -1803,6 +1826,27 @@ describe("ACPAgentSession Zed parity", () => {
]);
});
test("returns a select to its session default once its option is removed", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [copilotAgentConfigOption(request.value)],
}));
const session = createCopilotSessionWithConfig();
const { internals } = prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
asInternals<{ recordConfigFeatureDefaults(): void }>(internals).recordConfigFeatureDefaults();
await session.applyOptions({ agent: "Probe Agent" });
await session.applyOptions({});
expect(setSessionConfigOption).toHaveBeenLastCalledWith({
sessionId: "session-1",
configId: "agent",
value: "",
});
});
test("sets Copilot custom agent through ACP config options", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("Probe Agent")],
@@ -1757,6 +1757,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private readonly configFeatureDefaults = new Map<string, string>();
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2391,14 +2392,38 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
async applyOptions(options: ProviderOptions): Promise<void> {
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
await this.setConfigFeature(ACP_AUTO_ACCEPT_FEATURE_ID, options[ACP_AUTO_ACCEPT_FEATURE_ID]);
}
/**
* The value a select reported before Paseo wrote to it. An option removed from the agent's
* options returns the select to this value.
*/
private recordConfigFeatureDefaults(): void {
for (const featureOption of this.configFeatureOptions) {
if (!(featureOption.id in options)) continue;
const value = options[featureOption.id];
if (value === this.config.providerOptions?.[featureOption.id]) continue;
if (this.configFeatureDefaults.has(featureOption.id)) continue;
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current != null) this.configFeatureDefaults.set(featureOption.id, current);
}
}
async applyOptions(options: ProviderOptions): Promise<void> {
await this.setConfigFeature(
ACP_AUTO_ACCEPT_FEATURE_ID,
options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
);
for (const featureOption of this.configFeatureOptions) {
const explicit = Object.prototype.hasOwnProperty.call(options, featureOption.id);
const value = explicit
? options[featureOption.id]
: this.configFeatureDefaults.get(featureOption.id);
if (value === undefined) continue;
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (option && value === option.currentValue) continue;
if (!explicit && (!option || !findSelectConfigChoice({ option, value: String(value) }))) {
// A default recorded under a previous model may not exist on the current one.
continue;
}
await this.setConfigFeature(featureOption.id, value);
}
}
@@ -2972,6 +2997,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
: response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
this.recordConfigFeatureDefaults();
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -18,6 +18,9 @@ import {
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { AgentManager } from "../../agent-manager.js";
import { ensureAgentLoaded } from "../../agent-loading.js";
import { AgentStorage } from "../../agent-storage.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
@@ -1501,6 +1504,90 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("retries a fast mode change the runtime rejected", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
});
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
queryMock.applyFlagSettings.mockClear();
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
"runtime unavailable",
);
await session.applyOptions?.({ fast_mode: true });
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
await session.close();
});
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
const storagePath = path.join(root, "agents");
const makeClient = () => {
const mock = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory: mock.queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
return { ...mock, client };
};
const agentId = "00000000-0000-4000-8000-0000000000f1";
const before = makeClient();
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { claude: before.client },
registry: storage,
logger,
});
try {
await manager.createAgent(
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
agentId,
{ workspaceId: undefined },
);
await manager.setAgentOptions(agentId, { fast_mode: true });
await manager.closeAgent(agentId);
await manager.flush();
await storage.flush();
const after = makeClient();
const restartedStorage = new AgentStorage(storagePath, logger);
const restarted = new AgentManager({
clients: { claude: after.client },
registry: restartedStorage,
logger,
});
const agent = await ensureAgentLoaded(agentId, {
agentManager: restarted,
agentStorage: restartedStorage,
logger,
});
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
.session;
await session?.ensureQuery();
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
fastMode: true,
});
await restarted.closeAgent(agentId);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
@@ -2807,11 +2807,12 @@ class ClaudeAgentSession implements AgentSession {
}
const fastModeChanged =
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
this.config.providerOptions = providerOptions;
if (fastModeChanged) {
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
this.cachedRuntimeInfo = null;
}
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = providerOptions;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -505,6 +505,12 @@ describe("Codex app-server provider controls", () => {
});
});
test("applyOptions accepts an option that has no control", async () => {
const { session } = await createConnectedSession();
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).resolves.toBeUndefined();
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession();
@@ -516,6 +522,28 @@ describe("Codex app-server provider controls", () => {
});
});
test("shows a saved Fast preference as the tier the model resolves it to", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "fast" },
});
// Stored intent stays "fast"; the model's Fast tier is "priority", which is what runs and what is shown.
expect(session.displayOptions?.({ service_tier: "fast" })).toEqual({
service_tier: "priority",
});
expect(session.displayOptions?.({ fast_mode: true })).toMatchObject({
service_tier: "priority",
});
expect(session.displayOptions?.({ plan_mode: true })).toEqual({ plan_mode: true });
expect(session.displayOptions?.({ service_tier: "default" })).toEqual({
service_tier: "default",
});
expect(session.displayOptions?.({ service_tier: "gone" })).toEqual({});
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
const capture = createCapturedLogger();
const prompt = "secret prompt text should not be logged";
@@ -3861,7 +3861,10 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private get serviceTier(): string | null {
const requested = this.requestedServiceTier;
return this.resolveServiceTier(this.requestedServiceTier);
}
private resolveServiceTier(requested: string | null): string | null {
// Until the model list arrives there is nothing to check the request against.
if (this.speedModels.length === 0 || requested === "default") return requested;
const tiers = this.currentServiceTiers();
@@ -4670,6 +4673,13 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
}
displayOptions(options: ProviderOptions): ProviderOptions {
if (readCodexServiceTier(options) === null) return options;
const { service_tier: _stored, ...rest } = options;
const resolved = this.resolveServiceTier(readCodexServiceTier(options));
return resolved === null ? rest : { ...rest, service_tier: resolved };
}
private isServiceTierAvailable(tier: string | null): boolean {
if (tier === null || tier === "default") return true;
const tiers = this.currentServiceTiers();
@@ -290,6 +290,33 @@ describe("OpenCode auto_accept feature", () => {
await session.close();
});
test("stops auto-approving once auto_accept is removed from the options", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { auto_accept: true },
});
// The daemon passes effective options; with the key removed and no config default it is absent.
await session.applyOptions?.({});
await session.run("Run verification");
expect(openCodeClient.calls.permissionReply).toEqual([]);
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({ id: "permission-1" }),
]);
await session.close();
});
test("keeps questions separate from auto accept tool approval", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
@@ -4964,9 +4964,7 @@ class OpenCodeAgentSession implements AgentSession {
}
async applyOptions(options: ProviderOptions): Promise<void> {
if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) {
this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
}
this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
}
private setAutoAccept(enabled: boolean): void {
@@ -360,8 +360,8 @@ export class OpenCodeV2Session implements AgentSession {
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
async applyOptions(options: ProviderOptions) {
const value = options["auto_accept"];
if (value === undefined) return;
// An absent key is the default: approvals are asked for.
const value = options["auto_accept"] ?? false;
if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean");
this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value };
if (value && !this.config.toolPolicy)
@@ -18,7 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
import type { AgentManager } from "../agent-manager.js";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import { featuresFromControls, foldProfileFeatureValues } from "../option-controls.js";
import { featuresFromControls } from "../option-controls.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -3074,9 +3074,7 @@ function createToolCatalog(
},
},
async () => {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Stored profiles may
// still carry `featureValues`.
const profiles = (daemonConfigStore?.get().agentProfiles ?? []).map(foldProfileFeatureValues);
const profiles = daemonConfigStore?.get().agentProfiles ?? [];
return {
content: [],
structuredContent: ensureValidJson({ profiles }),
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import {
DaemonConfigStore,
applyMutableProviderConfigToOverrides,
toWireDaemonConfig,
} from "./daemon-config-store.js";
import { loadConfig } from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { readSpeechSettings } from "./speech/speech-settings.js";
@@ -196,13 +200,83 @@ describe("DaemonConfigStore", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
options: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
]);
expect(store.get().agentProfiles).toHaveLength(1);
});
test("a profile written with featureValues is stored as options, and the config API answers with both", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
// The unchanged app sends back the `options` it read plus the `featureValues` it edited.
await store.patch({
agentProfiles: [
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: false },
featureValues: { fast_mode: true },
},
],
});
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
]);
});
test("a profile stored with featureValues loads as options", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
test("patch replaces the whole agent profile list rather than merging entries", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -5,6 +5,11 @@ import {
type PersistedConfig,
} from "./persisted-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import {
foldProfileFeatureValues,
withLegacyProfileFeatureValues,
} from "./agent/option-controls.js";
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
import {
MutableDaemonConfigSchema,
MutableDaemonConfigPatchSchema,
@@ -168,6 +173,24 @@ function omitProvidersFromOverrides(
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
/** Profiles are held and persisted with `options` only. */
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
config: T,
source: "stored" | "client write",
): T {
if (!config.agentProfiles) return config;
return {
...config,
agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)),
};
}
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
if (!config.agentProfiles) return config;
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
@@ -380,7 +403,7 @@ export class DaemonConfigStore {
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
this.current = MutableDaemonConfigSchema.parse({
...initial,
...foldConfigProfiles(initial, "stored"),
relay: initial.relay ?? { enabled: true },
...this.readSpeech(this.startupPersisted),
});
@@ -418,7 +441,8 @@ export class DaemonConfigStore {
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
);
}
const { removeProviders = [], ...patch } = parsedPatch;
const { removeProviders = [], ...submittedPatch } = parsedPatch;
const patch = foldConfigProfiles(submittedPatch, "client write");
const { features: speechPatch, ...configPatch } = patch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
@@ -495,7 +519,7 @@ export class DaemonConfigStore {
// Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable.
const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable,
...foldConfigProfiles(resolved.mutable, "stored"),
plugins: this.current.plugins,
...this.readSpeech(persisted),
});
@@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: response.payload.agent.cwd,
});
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(response.payload.agent)).not.toContain("private-build");
// Provider options are not secret and appear in the snapshot's `options`.
expect(response.payload.agent.options).toMatchObject({
sandbox_workspace_write: { writable_roots: ["/var/cache/private-build"] },
});
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
@@ -104,7 +107,6 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: update.payload.agent.cwd,
});
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(update.payload.agent)).not.toContain("private-build");
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
});
+6 -3
View File
@@ -80,7 +80,7 @@ import {
createWorkspaceScriptsService,
type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { toWireDaemonConfig, type DaemonConfigStore } 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";
@@ -2949,7 +2949,10 @@ export class Session {
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(this.daemonConfigStore.get()),
},
});
return undefined;
case "daemon.get_status.request":
@@ -2973,7 +2976,7 @@ export class Session {
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
config: toWireDaemonConfig(config),
},
});
});