Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-settings-passthrough.md
Original file line number Diff line number Diff line change
@@ -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 <json>` 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).
23 changes: 23 additions & 0 deletions docs/src/content/docs/configuration/agent-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"` |
Expand Down Expand Up @@ -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 <json>` 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/<pid>/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.
Expand Down
11 changes: 11 additions & 0 deletions packages/core/src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <json>` 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(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ===========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ type _OurPluginsMatchTheOption = Extends<
NonNullable<SdkOptions["plugins"]>
>;

// --- settings: flag-tier settings object ----------------------------------

// Our `settings` must be something the SDK's `settings` option accepts.
type _OurSettingsMatchTheOption = Extends<
NonNullable<SDKQueryOptions["settings"]>,
NonNullable<SdkOptions["settings"]>
>;

// --- #445: MCP server transports -------------------------------------------

// Every transport the SDK's serializable configs declare must be expressible in
Expand Down
57 changes: 57 additions & 0 deletions packages/core/src/runner/runtime/__tests__/cli-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,63 @@ describe("CLIRuntime plugins (--plugin-dir)", () => {
});
});

describe("CLIRuntime flag settings (--settings)", () => {
async function captureArgs(agent: Record<string, unknown>): Promise<string[]> {
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 <redacted>");
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;
Expand Down
12 changes: 11 additions & 1 deletion packages/core/src/runner/runtime/cli-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// 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.
Expand Down Expand Up @@ -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" ? "<redacted>" : arg));
logger.debug(`Executing command: claude ${loggedArgs.join(" ")}`);
logger.debug(`Prompt: ${options.prompt}`);

// Track process and watcher for cleanup
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/runner/sdk-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/runner/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
resume?: string;
forkSession?: boolean;
/** Maximum number of agentic turns before stopping */
Expand Down
Loading