diff --git a/.changeset/agent-settings-passthrough.md b/.changeset/agent-settings-passthrough.md new file mode 100644 index 00000000..d320a7f4 --- /dev/null +++ b/.changeset/agent-settings-passthrough.md @@ -0,0 +1,5 @@ +--- +"@herdctl/core": minor +--- + +Add an agent-level `settings` field: Claude Code settings passed through at the flag tier, as the Agent SDK's `settings` option or `--settings ` on the CLI runtime. It lets an embedder set a key per agent that a project's checked-in `.claude/settings.json` is not trusted to set, such as `autoMemoryDirectory` (edspencer/paddock#955). diff --git a/docs/src/content/docs/configuration/agent-config.md b/docs/src/content/docs/configuration/agent-config.md index d2b82112..ab793efa 100644 --- a/docs/src/content/docs/configuration/agent-config.md +++ b/docs/src/content/docs/configuration/agent-config.md @@ -82,6 +82,7 @@ max_turns: 100 | `setting_sources` | string[] | No | Where Claude discovers settings: `user`, `project`, `local` | | `mcp_servers` | object | No | MCP server configurations | | `plugins` | array | No | Claude Code plugins to load (paths or `{type, path}` objects) | +| `settings` | object | No | Claude Code settings applied at the flag tier (`--settings`) | | `chat` | object | No | Chat integration settings | | `docker` | object | No | Docker execution settings | | `runtime` | string | No | Runtime type: `"sdk"` (default) or `"cli"` | @@ -774,6 +775,28 @@ Fleet [`defaults.plugins`](/configuration/fleet-config/#defaultsplugins) applies For a [dockerized agent](/configuration/docker/) the path must resolve *inside* the container, so the plugin directory has to be mounted there. ::: +### settings + +Claude Code [settings](https://docs.claude.com/en/docs/claude-code/settings) for this agent, applied at the **flag tier**: passed to the Agent SDK's `settings` option, or as `--settings ` on the [CLI runtime](#runtime). The object is passed through verbatim. Claude Code ignores an unknown key or an invalid value without reporting it, so check that a setting took effect rather than assuming it did. + +```yaml +settings: + autoMemoryDirectory: /srv/agents/coder/memory + effortLevel: high +``` + +Flag settings outrank every settings file except managed policy, and they apply whatever [`setting_sources`](#setting_sources) loads. That makes them the place for a key a project's checked-in `.claude/settings.json` is not trusted to set: Claude Code ignores `autoMemoryDirectory` there, but honours it here. + +Agent-level only; there is no fleet `defaults.settings`. + +:::caution[Don't put secrets in `settings`] +On both runtimes the object reaches Claude Code as a `--settings` command-line argument, so any local user can read it in `/proc//cmdline`. herdctl redacts it from its own debug log, but it can't hide the argv. Keep credentials out of it, including `env` entries and `${VAR}` values that interpolation would resolve. +::: + +:::note[`${...}` is interpolated] +In a YAML config, herdctl interpolates `${VAR}` and `${VAR:-default}` inside `settings` like any other field, and an unset variable with no default is an error. There is no escape syntax, so a value that must reach Claude Code with its own `${...}` intact, such as `${CLAUDE_PROJECT_DIR}` in a hook command, can't be written here. Put it in a project settings file instead, or register the agent programmatically, which skips interpolation. +::: + ### model Override the Claude model for this agent. diff --git a/packages/core/src/config/schema.ts b/packages/core/src/config/schema.ts index 62af2dc9..db1ec742 100644 --- a/packages/core/src/config/schema.ts +++ b/packages/core/src/config/schema.ts @@ -1075,6 +1075,17 @@ export const AgentConfigSchema = z * needs mounting. */ plugins: z.array(PluginSchema).optional(), + /** + * Claude Code settings applied at the flag tier — the SDK's `settings` + * option, or `--settings ` on the CLI runtime. + * + * Flag settings outrank every file source except managed policy, and they + * apply whatever `setting_sources` loads, so this is how an embedder sets a + * key per agent that a project's checked-in `.claude/settings.json` is not + * trusted to set (e.g. `autoMemoryDirectory`). Passed through verbatim; + * Claude Code validates the keys. + */ + settings: z.record(z.string(), z.unknown()).optional(), chat: AgentChatSchema.optional(), hooks: AgentHooksSchema.optional(), docker: AgentDockerSchema.optional(), diff --git a/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts b/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts index fc3dfccb..a80dd815 100644 --- a/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts +++ b/packages/core/src/runner/__tests__/mcp-and-plugin-passthrough.test.ts @@ -274,6 +274,48 @@ describe("MCP server + plugin passthrough (#444, #445)", () => { }); }); + // =========================================================================== + // settings — flag-tier settings passthrough + // =========================================================================== + + describe("settings reach SDKQueryOptions", () => { + it("survives addAgent (the strict schema) and reaches the SDK query() options", async () => { + const agent = await addAndResolve({ + name: "flag-settings", + settings: { autoMemoryDirectory: "/data/memory" }, + }); + + expect(agent.settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + expect(toSDKOptions(agent).settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + + const runtime = new SDKRuntime(); + for await (const _ of runtime.execute({ prompt: "hi", agent })) { + // mocked query() yields nothing + } + + expect(queryCalls).toHaveLength(1); + expect(queryCalls[0].settings).toEqual({ autoMemoryDirectory: "/data/memory" }); + }); + + it("omits the key when the agent sets none, or an empty object", async () => { + expect(toSDKOptions(await addAndResolve({ name: "no-settings" }))).not.toHaveProperty( + "settings", + ); + expect( + toSDKOptions(await addAndResolve({ name: "empty-settings", settings: {} })), + ).not.toHaveProperty("settings"); + }); + + it("does not alias the agent's object, so a runtime cannot mutate config", async () => { + const agent = await addAndResolve({ name: "isolated-settings", settings: { a: 1 } }); + + const sdkOptions = toSDKOptions(agent); + sdkOptions.settings!.a = 2; + + expect(agent.settings).toEqual({ a: 1 }); + }); + }); + // =========================================================================== // #444 second blocker — the settingSources lever that already exists // =========================================================================== diff --git a/packages/core/src/runner/__tests__/sdk-type-contract.test.ts b/packages/core/src/runner/__tests__/sdk-type-contract.test.ts index 27f20c68..c8fc3688 100644 --- a/packages/core/src/runner/__tests__/sdk-type-contract.test.ts +++ b/packages/core/src/runner/__tests__/sdk-type-contract.test.ts @@ -39,6 +39,14 @@ type _OurPluginsMatchTheOption = Extends< NonNullable >; +// --- settings: flag-tier settings object ---------------------------------- + +// Our `settings` must be something the SDK's `settings` option accepts. +type _OurSettingsMatchTheOption = Extends< + NonNullable, + NonNullable +>; + // --- #445: MCP server transports ------------------------------------------- // Every transport the SDK's serializable configs declare must be expressible in diff --git a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts index de10b2ae..40dbed9c 100644 --- a/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts +++ b/packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts @@ -465,6 +465,63 @@ describe("CLIRuntime plugins (--plugin-dir)", () => { }); }); +describe("CLIRuntime flag settings (--settings)", () => { + async function captureArgs(agent: Record): Promise { + let spawnedArgs: string[] = []; + + const runtime = new CLIRuntime({ + processSpawner: ((args: string[]) => { + spawnedArgs = args; + return makeSubprocess() as never; + }) as never, + }); + + for await (const _message of runtime.execute({ + prompt: "Hello", + agent: { name: "settings-agent", configPath: "/tmp/agent.yaml", ...agent } as never, + })) { + // drain + } + + return spawnedArgs; + } + + it("serialises the agent's settings into one --settings argument", async () => { + const args = await captureArgs({ + settings: { autoMemoryDirectory: "/data/memory", effortLevel: "high" }, + }); + + expect(args.filter((a) => a === "--settings")).toHaveLength(1); + expect(JSON.parse(args[args.indexOf("--settings") + 1])).toEqual({ + autoMemoryDirectory: "/data/memory", + effortLevel: "high", + }); + }); + + it("never writes the settings JSON to the debug log", async () => { + const debug = vi.spyOn(console, "debug").mockImplementation(() => {}); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + const prev = process.env.HERDCTL_LOG_LEVEL; + process.env.HERDCTL_LOG_LEVEL = "debug"; + try { + await captureArgs({ settings: { env: { TOKEN: "s3cret-value" } } }); + const logged = [...debug.mock.calls, ...log.mock.calls].flat().map(String).join("\n"); + expect(logged).toContain("--settings "); + expect(logged).not.toContain("s3cret-value"); + } finally { + if (prev === undefined) delete process.env.HERDCTL_LOG_LEVEL; + else process.env.HERDCTL_LOG_LEVEL = prev; + debug.mockRestore(); + log.mockRestore(); + } + }); + + it("emits no --settings when the agent sets none, or an empty object", async () => { + expect(await captureArgs({})).not.toContain("--settings"); + expect(await captureArgs({ settings: {} })).not.toContain("--settings"); + }); +}); + describe("CLIRuntime session fork (--fork-session)", () => { beforeEach(() => { watchMessages.length = 0; diff --git a/packages/core/src/runner/runtime/cli-runtime.ts b/packages/core/src/runner/runtime/cli-runtime.ts index edfb9b11..f08c9dff 100644 --- a/packages/core/src/runner/runtime/cli-runtime.ts +++ b/packages/core/src/runner/runtime/cli-runtime.ts @@ -291,6 +291,12 @@ export class CLIRuntime implements RuntimeInterface { } } + // Flag-tier settings — the same `--settings` the Agent SDK emits for its own + // `settings` option, so an agent's `settings` behaves the same on either runtime. + if (options.agent.settings && Object.keys(options.agent.settings).length > 0) { + args.push("--settings", JSON.stringify(options.agent.settings)); + } + // Collect MCP servers for --mcp-config. Claude CLI expects // {"mcpServers": { ... }} (same shape as .mcp.json). Declared servers are // transformed from agent config format; injected servers are added below. @@ -387,7 +393,11 @@ export class CLIRuntime implements RuntimeInterface { // Note: Prompt is NOT added to args - it's provided via stdin (see processSpawner call below) // DEBUG: Log the command being executed - logger.debug(`Executing command: claude ${args.join(" ")}`); + // `--settings` carries the agent's flag-tier settings verbatim, which can + // hold resolved secrets (`env`, `apiKeyHelper`, interpolated `${VAR}`s), so + // its value never reaches the log. + const loggedArgs = args.map((arg, i) => (args[i - 1] === "--settings" ? "" : arg)); + logger.debug(`Executing command: claude ${loggedArgs.join(" ")}`); logger.debug(`Prompt: ${options.prompt}`); // Track process and watcher for cleanup diff --git a/packages/core/src/runner/sdk-adapter.ts b/packages/core/src/runner/sdk-adapter.ts index 80ddae3b..75fb68b9 100644 --- a/packages/core/src/runner/sdk-adapter.ts +++ b/packages/core/src/runner/sdk-adapter.ts @@ -227,6 +227,13 @@ export function toSDKOptions( result.plugins = agent.plugins.map((plugin) => ({ ...plugin })); } + // Flag-tier settings. Omitted when the agent sets none, so the SDK passes no + // `--settings` at all. Shallow-copied for the same no-aliasing reason as + // `allowedTools` above. + if (agent.settings && Object.keys(agent.settings).length > 0) { + result.settings = { ...agent.settings }; + } + // Max turns limit (agent-level or session-level) const maxTurns = agent.max_turns ?? agent.session?.max_turns; if (maxTurns !== undefined) { diff --git a/packages/core/src/runner/types.ts b/packages/core/src/runner/types.ts index 3e9342dd..a1e4f74f 100644 --- a/packages/core/src/runner/types.ts +++ b/packages/core/src/runner/types.ts @@ -273,6 +273,12 @@ export interface SDKQueryOptions { * (edspencer/herdctl#444). */ plugins?: SDKPluginConfig[]; + /** + * Flag-tier Claude Code settings, from the agent's `settings` config. Set by + * `toSDKOptions` only when the agent declares a non-empty object; the SDK + * hands it to Claude Code as `--settings`. + */ + settings?: Record; resume?: string; forkSession?: boolean; /** Maximum number of agentic turns before stopping */