mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 04:32:57 +00:00
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:
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user