Keep configured Claude settings when Paseo adds flags (#8148)

* fix(claude): keep extraArgs settings when Paseo sets flag settings

* test(claude): verify settings survive the real SDK launch

Capture the executable arguments through the public provider session interface so SDK option precedence cannot silently discard the configured settings file again.

Co-authored-by: yayugu <yayugu@gmail.com>

---------

Co-authored-by: yayugu <yayugu@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-28 23:07:21 +00:00
committed by GitHub
co-authored by yayugu
parent d3f36e92a5
commit 0feaf0d987
3 changed files with 160 additions and 8 deletions
@@ -887,6 +887,68 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("keeps an extraArgs settings file when Paseo sets Claude flag settings", async () => {
const settingsDir = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-"));
const settingsPath = path.join(settingsDir, "settings.json");
await fs.writeFile(
settingsPath,
JSON.stringify({ env: { ENABLE_TOOL_SEARCH: "false" }, permissions: { deny: ["Monitor"] } }),
);
const { queryFactory } = 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-5-5",
providerOptions: {
extraArgs: { chrome: null, settings: settingsPath },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({ chrome: null });
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
fastMode: false,
});
await session.close();
await fs.rm(settingsDir, { recursive: true, force: true });
});
test("keeps inline extraArgs settings JSON for internal agents", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
internal: true,
providerOptions: {
extraArgs: { settings: '{"env":{"ENABLE_TOOL_SEARCH":"false"}}' },
},
});
await session.startTurn("hello");
const options = queryFactory.mock.calls[0]?.[0].options;
expect(options?.extraArgs).toEqual({});
expect(options?.settings).toEqual({
env: { ENABLE_TOOL_SEARCH: "false" },
disableAllHooks: true,
});
await session.close();
});
test("lists fast mode only for supported Opus models", async () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
@@ -48,6 +48,7 @@ import {
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { expandTilde } from "../../../../utils/path.js";
import { parsePartialJsonObject } from "./partial-json.js";
import { withClaudeReplayHistory, type ClaudeReplayHistory } from "./transcript-history.js";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
@@ -648,6 +649,40 @@ function readClaudeFastModeSetting(settings: ClaudeOptions["settings"]): boolean
return typeof settings.fastMode === "boolean" ? settings.fastMode : null;
}
type ClaudeSettingsObject = NonNullable<Exclude<ClaudeOptions["settings"], string>>;
// Mirrors `claude --settings`: the value is inline JSON or a path to a JSON file,
// resolved against the session cwd like the Claude process would.
async function readClaudeCliSettings(value: string, cwd: string): Promise<ClaudeSettingsObject> {
const trimmed = value.trim();
const isInlineJson = trimmed.startsWith("{");
const source = isInlineJson ? "--settings JSON" : path.resolve(cwd, expandTilde(trimmed));
let raw = trimmed;
if (!isInlineJson) {
try {
raw = await fsPromises.readFile(source, "utf8");
} catch (error) {
throw new Error(
`Failed to read Claude settings file ${source}: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (error) {
throw new Error(
`Claude ${source} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
{ cause: error },
);
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error(`Claude ${source} must contain a JSON object`);
}
return parsed as ClaudeSettingsObject;
}
function mergeClaudeSettings(
settings: ClaudeOptions["settings"],
updates: NonNullable<Exclude<ClaudeOptions["settings"], string>>,
@@ -3610,7 +3645,7 @@ class ClaudeAgentSession implements AgentSession {
this.config.providerOptions,
this.config.toolPolicy,
);
const settingsOptions = this.buildSettingsOptions(providerOptions, { ultracode });
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
@@ -3695,10 +3730,10 @@ class ClaudeAgentSession implements AgentSession {
return base;
}
private buildSettingsOptions(
private async buildSettingsOptions(
providerOptions: ClaudeProviderOptions,
input: { ultracode: boolean },
): Pick<ClaudeOptions, "settings"> | Record<string, never> {
): Promise<Pick<ClaudeOptions, "settings" | "extraArgs"> | Record<string, never>> {
const fastMode = this.resolveFastModeSetting();
// Internal agents do daemon work such as naming a branch, so the user's and
// project's hooks must not run for them.
@@ -3706,12 +3741,21 @@ class ClaudeAgentSession implements AgentSession {
if (fastMode === null && !input.ultracode && !disableAllHooks) {
return {};
}
const updates = {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
};
// The SDK replaces extraArgs.settings with Options.settings, so a user's
// `--settings` would be dropped whenever Paseo sets flag settings. Fold it in.
const { settings: extraArgsSettings, ...extraArgs } = providerOptions.extraArgs ?? {};
if (typeof extraArgsSettings !== "string") {
return { settings: mergeClaudeSettings(providerOptions.settings, updates) };
}
const cliSettings = await readClaudeCliSettings(extraArgsSettings, this.config.cwd);
return {
settings: mergeClaudeSettings(providerOptions.settings, {
...(fastMode === null ? {} : { fastMode }),
...(input.ultracode ? { ultracode: true } : {}),
...(disableAllHooks ? { disableAllHooks: true } : {}),
}),
extraArgs,
settings: { ...cliSettings, ...providerOptions.settings, ...updates },
};
}
@@ -0,0 +1,46 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
test("the real SDK passes the user's settings file contents alongside Paseo flags", async () => {
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-claude-settings-argv-"));
const capturePath = path.join(cwd, "argv.json");
const binaryPath = path.join(cwd, "capture.mjs");
const settingsPath = path.join(cwd, "settings.json");
const userSettings = {
env: { ENABLE_TOOL_SEARCH: "false" },
permissions: { deny: ["Monitor"] },
};
try {
// A CLI fixture records the real SDK's arguments without making a model request.
await fs.writeFile(
binaryPath,
`import fs from "node:fs"; fs.writeFileSync(${JSON.stringify(capturePath)}, JSON.stringify(process.argv.slice(2)));`,
);
await fs.writeFile(settingsPath, JSON.stringify(userSettings));
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
resolveBinary: async () => binaryPath,
});
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-opus-5-5",
providerOptions: { extraArgs: { settings: settingsPath } },
});
try {
await session.startTurn("hello");
} finally {
await session.close();
}
const argv: string[] = JSON.parse(await fs.readFile(capturePath, "utf8"));
const settingsIndex = argv.indexOf("--settings");
expect(settingsIndex).toBeGreaterThanOrEqual(0);
expect(JSON.parse(argv[settingsIndex + 1]!)).toEqual({ ...userSettings, fastMode: false });
} finally {
await fs.rm(cwd, { recursive: true, force: true });
}
});