diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 52b70c25bc..deb52f9a6b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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" }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index e2cd9dab8f..824a93f3da 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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>; + +// 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 { + 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>, @@ -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 | Record { + ): Promise | Record> { 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 }, }; } diff --git a/packages/server/src/server/agent/providers/claude/settings-argv.test.ts b/packages/server/src/server/agent/providers/claude/settings-argv.test.ts new file mode 100644 index 0000000000..36d1b9d794 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/settings-argv.test.ts @@ -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 }); + } +});