CLI: expand config set with SecretRef/provider builders and dry-run (#49296)

* CLI: expand config set ref/provider builder and dry-run

* Docs: revert README Discord token example
This commit is contained in:
Josh Avant 2026-03-17 18:15:49 -05:00 committed by GitHub
parent bd21442f7e
commit e99963100d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2102 additions and 32 deletions

View File

@ -364,7 +364,7 @@ Details: [Security guide](https://docs.openclaw.ai/gateway/security) · [Docker
### [Discord](https://docs.openclaw.ai/channels/discord)
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token` (env wins).
- Set `DISCORD_BOT_TOKEN` or `channels.discord.token`.
- Optional: set `commands.native`, `commands.text`, or `commands.useAccessGroups`, plus `channels.discord.allowFrom`, `channels.discord.guilds`, or `channels.discord.mediaMaxMb` as needed.
```json5

View File

@ -96,8 +96,10 @@ You will need to create a new application with a bot, add the bot to your server
Your Discord bot token is a secret (like a password). Set it on the machine running OpenClaw before messaging your agent.
```bash
openclaw config set channels.discord.token '"YOUR_BOT_TOKEN"' --json
openclaw config set channels.discord.enabled true --json
export DISCORD_BOT_TOKEN="YOUR_BOT_TOKEN"
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
openclaw config set channels.discord.enabled true --strict-json
openclaw gateway
```
@ -121,7 +123,11 @@ openclaw gateway
channels: {
discord: {
enabled: true,
token: "YOUR_BOT_TOKEN",
token: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
},
},
}
@ -133,7 +139,7 @@ openclaw gateway
DISCORD_BOT_TOKEN=...
```
SecretRef values are also supported for `channels.discord.token` (env/file/exec providers). See [Secrets Management](/gateway/secrets).
Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets).
</Tab>
</Tabs>

View File

@ -7,9 +7,9 @@ title: "config"
# `openclaw config`
Config helpers: get/set/unset/validate values by path and print the active
config file. Run without a subcommand to open
the configure wizard (same as `openclaw configure`).
Config helpers for non-interactive edits in `openclaw.json`: get/set/unset/validate
values by path and print the active config file. Run without a subcommand to
open the configure wizard (same as `openclaw configure`).
## Examples
@ -19,7 +19,10 @@ openclaw config get browser.executablePath
openclaw config set browser.executablePath "/usr/bin/google-chrome"
openclaw config set agents.defaults.heartbeat.every "2h"
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN
openclaw config set secrets.providers.vaultfile --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json
openclaw config unset tools.web.search.apiKey
openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run
openclaw config validate
openclaw config validate --json
```
@ -51,6 +54,211 @@ openclaw config set gateway.port 19001 --strict-json
openclaw config set channels.whatsapp.groups '["*"]' --strict-json
```
## `config set` modes
`openclaw config set` supports four assignment styles:
1. Value mode: `openclaw config set <path> <value>`
2. SecretRef builder mode:
```bash
openclaw config set channels.discord.token \
--ref-provider default \
--ref-source env \
--ref-id DISCORD_BOT_TOKEN
```
3. Provider builder mode (`secrets.providers.<alias>` path only):
```bash
openclaw config set secrets.providers.vault \
--provider-source exec \
--provider-command /usr/local/bin/openclaw-vault \
--provider-arg read \
--provider-arg openai/api-key \
--provider-timeout-ms 5000
```
4. Batch mode (`--batch-json` or `--batch-file`):
```bash
openclaw config set --batch-json '[
{
"path": "secrets.providers.default",
"provider": { "source": "env" }
},
{
"path": "channels.discord.token",
"ref": { "source": "env", "provider": "default", "id": "DISCORD_BOT_TOKEN" }
}
]'
```
```bash
openclaw config set --batch-file ./config-set.batch.json --dry-run
```
Batch parsing always uses the batch payload (`--batch-json`/`--batch-file`) as the source of truth.
`--strict-json` / `--json` do not change batch parsing behavior.
JSON path/value mode remains supported for both SecretRefs and providers:
```bash
openclaw config set channels.discord.token \
'{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}' \
--strict-json
openclaw config set secrets.providers.vaultfile \
'{"source":"file","path":"/etc/openclaw/secrets.json","mode":"json"}' \
--strict-json
```
## Provider Builder Flags
Provider builder targets must use `secrets.providers.<alias>` as the path.
Common flags:
- `--provider-source <env|file|exec>`
- `--provider-timeout-ms <ms>` (`file`, `exec`)
Env provider (`--provider-source env`):
- `--provider-allowlist <ENV_VAR>` (repeatable)
File provider (`--provider-source file`):
- `--provider-path <path>` (required)
- `--provider-mode <singleValue|json>`
- `--provider-max-bytes <bytes>`
Exec provider (`--provider-source exec`):
- `--provider-command <path>` (required)
- `--provider-arg <arg>` (repeatable)
- `--provider-no-output-timeout-ms <ms>`
- `--provider-max-output-bytes <bytes>`
- `--provider-json-only`
- `--provider-env <KEY=VALUE>` (repeatable)
- `--provider-pass-env <ENV_VAR>` (repeatable)
- `--provider-trusted-dir <path>` (repeatable)
- `--provider-allow-insecure-path`
- `--provider-allow-symlink-command`
Hardened exec provider example:
```bash
openclaw config set secrets.providers.vault \
--provider-source exec \
--provider-command /usr/local/bin/openclaw-vault \
--provider-arg read \
--provider-arg openai/api-key \
--provider-json-only \
--provider-pass-env VAULT_TOKEN \
--provider-trusted-dir /usr/local/bin \
--provider-timeout-ms 5000
```
## Dry run
Use `--dry-run` to validate changes without writing `openclaw.json`.
```bash
openclaw config set channels.discord.token \
--ref-provider default \
--ref-source env \
--ref-id DISCORD_BOT_TOKEN \
--dry-run
openclaw config set channels.discord.token \
--ref-provider default \
--ref-source env \
--ref-id DISCORD_BOT_TOKEN \
--dry-run \
--json
```
Dry-run behavior:
- Builder mode: requires full SecretRef resolvability for changed refs/providers.
- JSON mode (`--strict-json`, `--json`, or batch mode): requires full resolvability and schema validation.
`--dry-run --json` prints a machine-readable report:
- `ok`: whether dry-run passed
- `operations`: number of assignments evaluated
- `checks`: whether schema/resolvability checks ran
- `refsChecked`: number of refs resolved during dry-run
- `errors`: structured schema/resolvability failures when `ok=false`
### JSON Output Shape
```json5
{
ok: boolean,
operations: number,
configPath: string,
inputModes: ["value" | "json" | "builder", ...],
checks: {
schema: boolean,
resolvability: boolean,
},
refsChecked: number,
errors?: [
{
kind: "schema" | "resolvability",
message: string,
ref?: string, // present for resolvability errors
},
],
}
```
Success example:
```json
{
"ok": true,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
},
"refsChecked": 1
}
```
Failure example:
```json
{
"ok": false,
"operations": 1,
"configPath": "~/.openclaw/openclaw.json",
"inputModes": ["builder"],
"checks": {
"schema": false,
"resolvability": true
},
"refsChecked": 1,
"errors": [
{
"kind": "resolvability",
"message": "Error: Environment variable \"MISSING_TEST_SECRET\" is not set.",
"ref": "env:default:MISSING_TEST_SECRET"
}
]
}
```
If dry-run fails:
- `config schema validation failed`: your post-change config shape is invalid; fix path/value or provider/ref object shape.
- `SecretRef assignment(s) could not be resolved`: referenced provider/ref currently cannot resolve (missing env var, invalid file pointer, exec provider failure, or provider/source mismatch).
- For batch mode, fix failing entries and rerun `--dry-run` before writing.
## Subcommands
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).

View File

@ -101,6 +101,8 @@ openclaw [--dev] [--profile <name>] <command>
get
set
unset
file
validate
completion
doctor
dashboard
@ -393,7 +395,14 @@ subcommand launches the wizard.
Subcommands:
- `config get <path>`: print a config value (dot/bracket path).
- `config set <path> <value>`: set a value (JSON5 or raw string).
- `config set`: supports four assignment modes:
- value mode: `config set <path> <value>` (JSON5-or-string parsing)
- SecretRef builder mode: `config set <path> --ref-provider <provider> --ref-source <source> --ref-id <id>`
- provider builder mode: `config set secrets.providers.<alias> --provider-source <env|file|exec> ...`
- batch mode: `config set --batch-json '<json>'` or `config set --batch-file <path>`
- `config set --dry-run`: validate assignments without writing `openclaw.json`.
- `config set --dry-run --json`: emit machine-readable dry-run output (checks, operations, errors).
- `config set --strict-json`: require JSON5 parsing for path/value input. `--json` remains a legacy alias for strict parsing outside dry-run output mode.
- `config unset <path>`: remove a value.
- `config file`: print the active config file path.
- `config validate`: validate the current config against the schema without starting the gateway.

View File

@ -0,0 +1,186 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js";
import { captureEnv } from "../test-utils/env.js";
import { runConfigSet } from "./config-cli.js";
function createTestRuntime() {
const logs: string[] = [];
const errors: string[] = [];
return {
logs,
errors,
runtime: {
log: (...args: unknown[]) => logs.push(args.map((arg) => String(arg)).join(" ")),
error: (...args: unknown[]) => errors.push(args.map((arg) => String(arg)).join(" ")),
exit: (code: number) => {
throw new Error(`__exit__:${code}`);
},
},
};
}
describe("config cli integration", () => {
it("supports batch-file dry-run and then writes real config changes", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-"));
const configPath = path.join(tempDir, "openclaw.json");
const batchPath = path.join(tempDir, "batch.json");
const envSnapshot = captureEnv([
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_TEST_FAST",
"DISCORD_BOT_TOKEN",
]);
try {
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
gateway: { port: 18789 },
},
null,
2,
)}\n`,
"utf8",
);
fs.writeFileSync(
batchPath,
`${JSON.stringify(
[
{
path: "secrets.providers.default",
provider: { source: "env" },
},
{
path: "channels.discord.token",
ref: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
},
],
null,
2,
)}\n`,
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
process.env.DISCORD_BOT_TOKEN = "test-token";
clearConfigCache();
clearRuntimeConfigSnapshot();
const runtime = createTestRuntime();
const before = fs.readFileSync(configPath, "utf8");
await runConfigSet({
cliOptions: {
batchFile: batchPath,
dryRun: true,
},
runtime: runtime.runtime,
});
const afterDryRun = fs.readFileSync(configPath, "utf8");
expect(afterDryRun).toBe(before);
expect(runtime.errors).toEqual([]);
expect(runtime.logs.some((line) => line.includes("Dry run successful: 2 update(s)"))).toBe(
true,
);
await runConfigSet({
cliOptions: {
batchFile: batchPath,
},
runtime: runtime.runtime,
});
const afterWrite = JSON5.parse(fs.readFileSync(configPath, "utf8"));
expect(afterWrite.secrets?.providers?.default).toEqual({
source: "env",
});
expect(afterWrite.channels?.discord?.token).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("keeps file unchanged when real-file dry-run fails and reports JSON error payload", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-cli-int-fail-"));
const configPath = path.join(tempDir, "openclaw.json");
const envSnapshot = captureEnv([
"OPENCLAW_CONFIG_PATH",
"OPENCLAW_TEST_FAST",
"MISSING_TEST_SECRET",
]);
try {
fs.writeFileSync(
configPath,
`${JSON.stringify(
{
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
},
null,
2,
)}\n`,
"utf8",
);
process.env.OPENCLAW_TEST_FAST = "1";
process.env.OPENCLAW_CONFIG_PATH = configPath;
delete process.env.MISSING_TEST_SECRET;
clearConfigCache();
clearRuntimeConfigSnapshot();
const runtime = createTestRuntime();
const before = fs.readFileSync(configPath, "utf8");
await expect(
runConfigSet({
path: "channels.discord.token",
cliOptions: {
refProvider: "default",
refSource: "env",
refId: "MISSING_TEST_SECRET",
dryRun: true,
json: true,
},
runtime: runtime.runtime,
}),
).rejects.toThrow("__exit__:1");
const after = fs.readFileSync(configPath, "utf8");
expect(after).toBe(before);
expect(runtime.errors).toEqual([]);
const raw = runtime.logs.at(-1);
expect(raw).toBeTruthy();
const payload = JSON.parse(raw ?? "{}") as {
ok?: boolean;
checks?: { schema?: boolean; resolvability?: boolean };
errors?: Array<{ kind?: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
expect(payload.checks?.resolvability).toBe(true);
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
expect(payload.errors?.some((entry) => entry.ref?.includes("MISSING_TEST_SECRET"))).toBe(
true,
);
} finally {
envSnapshot.restore();
clearConfigCache();
clearRuntimeConfigSnapshot();
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});

View File

@ -1,3 +1,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigFileSnapshot, OpenClawConfig } from "../config/types.js";
@ -12,6 +15,7 @@ const mockReadConfigFileSnapshot = vi.fn<() => Promise<ConfigFileSnapshot>>();
const mockWriteConfigFile = vi.fn<
(cfg: OpenClawConfig, options?: { unsetPaths?: string[][] }) => Promise<void>
>(async () => {});
const mockResolveSecretRefValue = vi.fn();
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mockReadConfigFileSnapshot(),
@ -19,6 +23,10 @@ vi.mock("../config/config.js", () => ({
mockWriteConfigFile(cfg, options),
}));
vi.mock("../secrets/resolve.js", () => ({
resolveSecretRefValue: (...args: unknown[]) => mockResolveSecretRefValue(...args),
}));
const mockLog = vi.fn();
const mockError = vi.fn();
const mockExit = vi.fn((code: number) => {
@ -123,6 +131,7 @@ describe("config cli", () => {
beforeEach(() => {
vi.clearAllMocks();
mockResolveSecretRefValue.mockResolvedValue("resolved-secret");
});
describe("config set - issue #6070", () => {
@ -345,6 +354,23 @@ describe("config cli", () => {
expect(mockReadConfigFileSnapshot).not.toHaveBeenCalled();
});
it("accepts --strict-json with batch mode and applies batch payload", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"gateway.auth.mode","value":"token"}]',
"--strict-json",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.gateway?.auth).toEqual({ mode: "token" });
});
it("shows --strict-json and keeps --json as a legacy alias in help", async () => {
const program = new Command();
registerConfigCli(program);
@ -356,6 +382,485 @@ describe("config cli", () => {
expect(helpText).toContain("--strict-json");
expect(helpText).toContain("--json");
expect(helpText).toContain("Legacy alias for --strict-json");
expect(helpText).toContain("--ref-provider");
expect(helpText).toContain("--provider-source");
expect(helpText).toContain("--batch-json");
expect(helpText).toContain("--dry-run");
expect(helpText).toContain("openclaw config set gateway.port 19001 --strict-json");
expect(helpText).toContain(
"openclaw config set channels.discord.token --ref-provider default --ref-source",
);
expect(helpText).toContain("--ref-id DISCORD_BOT_TOKEN");
expect(helpText).toContain(
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
);
});
});
describe("config set builders and dry-run", () => {
it("supports SecretRef builder mode without requiring a value argument", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.channels?.discord?.token).toEqual({
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
});
});
it("supports provider builder mode under secrets.providers.<alias>", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"secrets.providers.vaultfile",
"--provider-source",
"file",
"--provider-path",
"/tmp/vault.json",
"--provider-mode",
"json",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.secrets?.providers?.vaultfile).toEqual({
source: "file",
path: "/tmp/vault.json",
mode: "json",
});
});
it("runs resolvability checks in builder dry-run mode without writing", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
expect(mockResolveSecretRefValue).toHaveBeenCalledWith(
{
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
expect.objectContaining({
env: expect.any(Object),
}),
);
});
it("requires schema validation in JSON dry-run mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
};
setSnapshot(resolved, resolved);
await expect(
runConfigCommand([
"config",
"set",
"gateway.port",
'"not-a-number"',
"--strict-json",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: config schema validation failed."),
);
});
it("supports batch mode for refs/providers in dry-run", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
"--dry-run",
]);
expect(mockWriteConfigFile).not.toHaveBeenCalled();
expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1);
});
it("writes sibling SecretRef paths when target uses sibling-ref shape", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
channels: {
googlechat: {
enabled: true,
} as never,
} as never,
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.googlechat.serviceAccount",
"--ref-provider",
"vaultfile",
"--ref-source",
"file",
"--ref-id",
"/providers/googlechat/serviceAccount",
]);
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.channels?.googlechat?.serviceAccountRef).toEqual({
source: "file",
provider: "vaultfile",
id: "/providers/googlechat/serviceAccount",
});
expect(written.channels?.googlechat?.serviceAccount).toBeUndefined();
});
it("rejects mixing ref-builder and provider-builder flags", async () => {
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--provider-source",
"env",
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("config set mode error: choose exactly one mode"),
);
});
it("rejects mixing batch mode with builder flags", async () => {
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
"[]",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining(
"config set mode error: batch mode (--batch-json/--batch-file) cannot be combined",
),
);
});
it("supports batch-file mode", async () => {
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
setSnapshot(resolved, resolved);
const pathname = path.join(
os.tmpdir(),
`openclaw-config-batch-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.writeFileSync(pathname, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
try {
await runConfigCommand(["config", "set", "--batch-file", pathname]);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockWriteConfigFile).toHaveBeenCalledTimes(1);
const written = mockWriteConfigFile.mock.calls[0]?.[0];
expect(written.gateway?.auth).toEqual({ mode: "token" });
});
it("rejects malformed batch-file payloads", async () => {
const pathname = path.join(
os.tmpdir(),
`openclaw-config-batch-invalid-${Date.now()}-${Math.random().toString(16).slice(2)}.json`,
);
fs.writeFileSync(pathname, '{"path":"gateway.auth.mode","value":"token"}', "utf8");
try {
await expect(runConfigCommand(["config", "set", "--batch-file", pathname])).rejects.toThrow(
"__exit__:1",
);
} finally {
fs.rmSync(pathname, { force: true });
}
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("--batch-file must be a JSON array."),
);
});
it("rejects malformed batch entries with mixed operation keys", async () => {
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"channels.discord.token","value":"x","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("must include exactly one of: value, ref, provider"),
);
});
it("fails dry-run when a builder-assigned SecretRef is unresolved", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
);
});
it("emits structured JSON for --dry-run --json success", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
await runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
"--json",
]);
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
checks: { schema: boolean; resolvability: boolean };
refsChecked: number;
operations: number;
};
expect(payload.ok).toBe(true);
expect(payload.operations).toBe(1);
expect(payload.refsChecked).toBe(1);
expect(payload.checks).toEqual({
schema: false,
resolvability: true,
});
});
it("emits structured JSON for --dry-run --json failure", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValueOnce(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"channels.discord.token",
"--ref-provider",
"default",
"--ref-source",
"env",
"--ref-id",
"DISCORD_BOT_TOKEN",
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
expect(
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
).toBe(true);
});
it("aggregates schema and resolvability failures in --dry-run --json mode", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
default: { source: "env" },
},
},
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var"));
await expect(
runConfigCommand([
"config",
"set",
"--batch-json",
'[{"path":"gateway.port","value":"not-a-number"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}}]',
"--dry-run",
"--json",
]),
).rejects.toThrow("__exit__:1");
const raw = mockLog.mock.calls.at(-1)?.[0];
expect(typeof raw).toBe("string");
const payload = JSON.parse(String(raw)) as {
ok: boolean;
errors?: Array<{ kind: string; message: string; ref?: string }>;
};
expect(payload.ok).toBe(false);
expect(payload.errors?.some((entry) => entry.kind === "schema")).toBe(true);
expect(payload.errors?.some((entry) => entry.kind === "resolvability")).toBe(true);
expect(
payload.errors?.some((entry) => entry.ref?.includes("default:DISCORD_BOT_TOKEN")),
).toBe(true);
});
it("fails dry-run when provider updates make existing refs unresolvable", async () => {
const resolved: OpenClawConfig = {
gateway: { port: 18789 },
secrets: {
providers: {
vaultfile: { source: "file", path: "/tmp/secrets.json", mode: "json" },
},
},
tools: {
web: {
search: {
enabled: true,
apiKey: {
source: "file",
provider: "vaultfile",
id: "/providers/search/apiKey",
},
},
},
} as never,
};
setSnapshot(resolved, resolved);
mockResolveSecretRefValue.mockImplementationOnce(async () => {
throw new Error("provider mismatch");
});
await expect(
runConfigCommand([
"config",
"set",
"secrets.providers.vaultfile",
"--provider-source",
"env",
"--dry-run",
]),
).rejects.toThrow("__exit__:1");
expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("Dry run failed: 1 SecretRef assignment(s) could not be resolved."),
);
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("provider mismatch"));
});
});

View File

@ -1,26 +1,100 @@
import type { Command } from "commander";
import JSON5 from "json5";
import { OLLAMA_DEFAULT_BASE_URL } from "../agents/ollama-defaults.js";
import type { OpenClawConfig } from "../config/config.js";
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js";
import { CONFIG_PATH } from "../config/paths.js";
import { isBlockedObjectKey } from "../config/prototype-keys.js";
import { redactConfigObject } from "../config/redact-snapshot.js";
import {
coerceSecretRef,
isValidEnvSecretRefId,
resolveSecretInputRef,
type SecretProviderConfig,
type SecretRef,
type SecretRefSource,
} from "../config/types.secrets.js";
import { validateConfigObjectRaw } from "../config/validation.js";
import { SecretProviderSchema } from "../config/zod-schema.core.js";
import { danger, info, success } from "../globals.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import {
formatExecSecretRefIdValidationMessage,
isValidFileSecretRefId,
isValidSecretProviderAlias,
secretRefKey,
validateExecSecretRefId,
} from "../secrets/ref-contract.js";
import { resolveSecretRefValue } from "../secrets/resolve.js";
import {
discoverConfigSecretTargets,
resolveConfigSecretTargetByPath,
} from "../secrets/target-registry.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
import type {
ConfigSetDryRunError,
ConfigSetDryRunInputMode,
ConfigSetDryRunResult,
} from "./config-set-dryrun.js";
import {
hasProviderBuilderOptions,
hasRefBuilderOptions,
parseBatchSource,
type ConfigSetBatchEntry,
type ConfigSetOptions,
} from "./config-set-input.js";
import { resolveConfigSetMode } from "./config-set-parser.js";
type PathSegment = string;
type ConfigSetParseOpts = {
strictJson?: boolean;
};
type ConfigSetInputMode = ConfigSetDryRunInputMode;
type ConfigSetOperation = {
inputMode: ConfigSetInputMode;
requestedPath: PathSegment[];
setPath: PathSegment[];
value: unknown;
touchedSecretTargetPath?: string;
touchedProviderAlias?: string;
assignedRef?: SecretRef;
};
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"];
const CONFIG_SET_EXAMPLE_VALUE = formatCliCommand(
"openclaw config set gateway.port 19001 --strict-json",
);
const CONFIG_SET_EXAMPLE_REF = formatCliCommand(
"openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN",
);
const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand(
"openclaw config set secrets.providers.vault --provider-source file --provider-path /etc/openclaw/secrets.json --provider-mode json",
);
const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand(
"openclaw config set --batch-file ./config-set.batch.json --dry-run",
);
const CONFIG_SET_DESCRIPTION = [
"Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).",
"Examples:",
CONFIG_SET_EXAMPLE_VALUE,
CONFIG_SET_EXAMPLE_REF,
CONFIG_SET_EXAMPLE_PROVIDER,
CONFIG_SET_EXAMPLE_BATCH,
].join("\n");
class ConfigSetDryRunValidationError extends Error {
constructor(readonly result: ConfigSetDryRunResult) {
super("config set dry-run validation failed");
this.name = "ConfigSetDryRunValidationError";
}
}
function isIndexSegment(raw: string): boolean {
return /^[0-9]+$/.test(raw);
@ -276,6 +350,628 @@ function ensureValidOllamaProviderForApiKeySet(
});
}
function toDotPath(path: PathSegment[]): string {
return path.join(".");
}
function parseSecretRefSource(raw: string, label: string): SecretRefSource {
const source = raw.trim();
if (source === "env" || source === "file" || source === "exec") {
return source;
}
throw new Error(`${label} must be one of: env, file, exec.`);
}
function parseSecretRefBuilder(params: {
provider: string;
source: string;
id: string;
fieldPrefix: string;
}): SecretRef {
const provider = params.provider.trim();
if (!provider) {
throw new Error(`${params.fieldPrefix}.provider is required.`);
}
if (!isValidSecretProviderAlias(provider)) {
throw new Error(
`${params.fieldPrefix}.provider must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
);
}
const source = parseSecretRefSource(params.source, `${params.fieldPrefix}.source`);
const id = params.id.trim();
if (!id) {
throw new Error(`${params.fieldPrefix}.id is required.`);
}
if (source === "env" && !isValidEnvSecretRefId(id)) {
throw new Error(`${params.fieldPrefix}.id must match /^[A-Z][A-Z0-9_]{0,127}$/ for env refs.`);
}
if (source === "file" && !isValidFileSecretRefId(id)) {
throw new Error(
`${params.fieldPrefix}.id must be an absolute JSON pointer (or "value" for singleValue mode).`,
);
}
if (source === "exec") {
const validated = validateExecSecretRefId(id);
if (!validated.ok) {
throw new Error(formatExecSecretRefIdValidationMessage());
}
}
return { source, provider, id };
}
function parseOptionalPositiveInteger(raw: string | undefined, flag: string): number | undefined {
if (raw === undefined) {
return undefined;
}
const trimmed = raw.trim();
if (!trimmed) {
throw new Error(`${flag} must not be empty.`);
}
const parsed = Number(trimmed);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${flag} must be a positive integer.`);
}
return parsed;
}
function parseProviderEnvEntries(
entries: string[] | undefined,
): Record<string, string> | undefined {
if (!entries || entries.length === 0) {
return undefined;
}
const env: Record<string, string> = {};
for (const entry of entries) {
const separator = entry.indexOf("=");
if (separator <= 0) {
throw new Error(`--provider-env expects KEY=VALUE entries (received: "${entry}").`);
}
const key = entry.slice(0, separator).trim();
if (!key) {
throw new Error(`--provider-env key must not be empty (received: "${entry}").`);
}
env[key] = entry.slice(separator + 1);
}
return Object.keys(env).length > 0 ? env : undefined;
}
function parseProviderAliasPath(path: PathSegment[]): string {
const expectedPrefixMatches =
path.length === 3 &&
path[0] === SECRET_PROVIDER_PATH_PREFIX[0] &&
path[1] === SECRET_PROVIDER_PATH_PREFIX[1];
if (!expectedPrefixMatches) {
throw new Error(
'Provider builder mode requires path "secrets.providers.<alias>" (example: secrets.providers.vault).',
);
}
const alias = path[2] ?? "";
if (!isValidSecretProviderAlias(alias)) {
throw new Error(
`Provider alias "${alias}" must match /^[a-z][a-z0-9_-]{0,63}$/ (example: "default").`,
);
}
return alias;
}
function buildProviderFromBuilder(opts: ConfigSetOptions): SecretProviderConfig {
const sourceRaw = opts.providerSource?.trim();
if (!sourceRaw) {
throw new Error("--provider-source is required in provider builder mode.");
}
const source = parseSecretRefSource(sourceRaw, "--provider-source");
const timeoutMs = parseOptionalPositiveInteger(opts.providerTimeoutMs, "--provider-timeout-ms");
const maxBytes = parseOptionalPositiveInteger(opts.providerMaxBytes, "--provider-max-bytes");
const noOutputTimeoutMs = parseOptionalPositiveInteger(
opts.providerNoOutputTimeoutMs,
"--provider-no-output-timeout-ms",
);
const maxOutputBytes = parseOptionalPositiveInteger(
opts.providerMaxOutputBytes,
"--provider-max-output-bytes",
);
const providerEnv = parseProviderEnvEntries(opts.providerEnv);
let provider: SecretProviderConfig;
if (source === "env") {
const allowlist = (opts.providerAllowlist ?? []).map((entry) => entry.trim()).filter(Boolean);
for (const envName of allowlist) {
if (!isValidEnvSecretRefId(envName)) {
throw new Error(
`--provider-allowlist entry "${envName}" must match /^[A-Z][A-Z0-9_]{0,127}$/.`,
);
}
}
provider = {
source: "env",
...(allowlist.length > 0 ? { allowlist } : {}),
};
} else if (source === "file") {
const filePath = opts.providerPath?.trim();
if (!filePath) {
throw new Error("--provider-path is required when --provider-source file is used.");
}
const modeRaw = opts.providerMode?.trim();
if (modeRaw && modeRaw !== "singleValue" && modeRaw !== "json") {
throw new Error("--provider-mode must be one of: singleValue, json.");
}
const mode = modeRaw === "singleValue" || modeRaw === "json" ? modeRaw : undefined;
provider = {
source: "file",
path: filePath,
...(mode ? { mode } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(maxBytes !== undefined ? { maxBytes } : {}),
};
} else {
const command = opts.providerCommand?.trim();
if (!command) {
throw new Error("--provider-command is required when --provider-source exec is used.");
}
provider = {
source: "exec",
command,
...(opts.providerArg && opts.providerArg.length > 0
? { args: opts.providerArg.map((entry) => entry.trim()) }
: {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(noOutputTimeoutMs !== undefined ? { noOutputTimeoutMs } : {}),
...(maxOutputBytes !== undefined ? { maxOutputBytes } : {}),
...(opts.providerJsonOnly ? { jsonOnly: true } : {}),
...(providerEnv ? { env: providerEnv } : {}),
...(opts.providerPassEnv && opts.providerPassEnv.length > 0
? { passEnv: opts.providerPassEnv.map((entry) => entry.trim()).filter(Boolean) }
: {}),
...(opts.providerTrustedDir && opts.providerTrustedDir.length > 0
? { trustedDirs: opts.providerTrustedDir.map((entry) => entry.trim()).filter(Boolean) }
: {}),
...(opts.providerAllowInsecurePath ? { allowInsecurePath: true } : {}),
...(opts.providerAllowSymlinkCommand ? { allowSymlinkCommand: true } : {}),
};
}
const validated = SecretProviderSchema.safeParse(provider);
if (!validated.success) {
const issue = validated.error.issues[0];
const issuePath = issue?.path?.join(".") ?? "<provider>";
const issueMessage = issue?.message ?? "Invalid provider config.";
throw new Error(`Provider builder config invalid at ${issuePath}: ${issueMessage}`);
}
return validated.data;
}
function parseSecretRefFromUnknown(value: unknown, label: string): SecretRef {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(`${label} must be an object with source/provider/id.`);
}
const candidate = value as Record<string, unknown>;
if (
typeof candidate.provider !== "string" ||
typeof candidate.source !== "string" ||
typeof candidate.id !== "string"
) {
throw new Error(`${label} must include string fields: source, provider, id.`);
}
return parseSecretRefBuilder({
provider: candidate.provider,
source: candidate.source,
id: candidate.id,
fieldPrefix: label,
});
}
function buildRefAssignmentOperation(params: {
requestedPath: PathSegment[];
ref: SecretRef;
inputMode: ConfigSetInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
if (resolved?.entry.secretShape === "sibling_ref" && resolved.refPathSegments) {
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: resolved.refPathSegments,
value: params.ref,
touchedSecretTargetPath: toDotPath(resolved.pathSegments),
assignedRef: params.ref,
...(resolved.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
}
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: params.requestedPath,
value: params.ref,
touchedSecretTargetPath: resolved
? toDotPath(resolved.pathSegments)
: toDotPath(params.requestedPath),
assignedRef: params.ref,
...(resolved?.providerId ? { touchedProviderAlias: resolved.providerId } : {}),
};
}
function parseProviderAliasFromTargetPath(path: PathSegment[]): string | null {
if (
path.length === 3 &&
path[0] === SECRET_PROVIDER_PATH_PREFIX[0] &&
path[1] === SECRET_PROVIDER_PATH_PREFIX[1]
) {
return path[2] ?? null;
}
return null;
}
function buildValueAssignmentOperation(params: {
requestedPath: PathSegment[];
value: unknown;
inputMode: ConfigSetInputMode;
}): ConfigSetOperation {
const resolved = resolveConfigSecretTargetByPath(params.requestedPath);
const providerAlias = parseProviderAliasFromTargetPath(params.requestedPath);
const coercedRef = coerceSecretRef(params.value);
return {
inputMode: params.inputMode,
requestedPath: params.requestedPath,
setPath: params.requestedPath,
value: params.value,
...(resolved ? { touchedSecretTargetPath: toDotPath(resolved.pathSegments) } : {}),
...(providerAlias ? { touchedProviderAlias: providerAlias } : {}),
...(coercedRef ? { assignedRef: coercedRef } : {}),
};
}
function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperation[] {
const operations: ConfigSetOperation[] = [];
for (const [index, entry] of entries.entries()) {
const path = parseRequiredPath(entry.path);
if (entry.ref !== undefined) {
const ref = parseSecretRefFromUnknown(entry.ref, `batch[${index}].ref`);
operations.push(
buildRefAssignmentOperation({
requestedPath: path,
ref,
inputMode: "json",
}),
);
continue;
}
if (entry.provider !== undefined) {
const alias = parseProviderAliasPath(path);
const validated = SecretProviderSchema.safeParse(entry.provider);
if (!validated.success) {
const issue = validated.error.issues[0];
const issuePath = issue?.path?.join(".") ?? "<provider>";
throw new Error(
`batch[${index}].provider invalid at ${issuePath}: ${issue?.message ?? ""}`,
);
}
operations.push({
inputMode: "json",
requestedPath: path,
setPath: path,
value: validated.data,
touchedProviderAlias: alias,
});
continue;
}
operations.push(
buildValueAssignmentOperation({
requestedPath: path,
value: entry.value,
inputMode: "json",
}),
);
}
return operations;
}
function modeError(message: string): Error {
return new Error(`config set mode error: ${message}`);
}
function buildSingleSetOperations(params: {
path?: string;
value?: string;
opts: ConfigSetOptions;
}): ConfigSetOperation[] {
const pathProvided = typeof params.path === "string" && params.path.trim().length > 0;
const parsedPath = pathProvided ? parseRequiredPath(params.path as string) : null;
const strictJson = Boolean(params.opts.strictJson || params.opts.json);
const modeResolution = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: hasRefBuilderOptions(params.opts),
hasProviderBuilderOptions: hasProviderBuilderOptions(params.opts),
strictJson,
});
if (!modeResolution.ok) {
throw modeError(modeResolution.error);
}
if (modeResolution.mode === "ref_builder") {
if (!pathProvided || !parsedPath) {
throw modeError("ref builder mode requires <path>.");
}
if (params.value !== undefined) {
throw modeError("ref builder mode does not accept <value>.");
}
if (!params.opts.refProvider || !params.opts.refSource || !params.opts.refId) {
throw modeError(
"ref builder mode requires --ref-provider <alias>, --ref-source <env|file|exec>, and --ref-id <id>.",
);
}
const ref = parseSecretRefBuilder({
provider: params.opts.refProvider,
source: params.opts.refSource,
id: params.opts.refId,
fieldPrefix: "ref",
});
return [
buildRefAssignmentOperation({
requestedPath: parsedPath,
ref,
inputMode: "builder",
}),
];
}
if (modeResolution.mode === "provider_builder") {
if (!pathProvided || !parsedPath) {
throw modeError("provider builder mode requires <path>.");
}
if (params.value !== undefined) {
throw modeError("provider builder mode does not accept <value>.");
}
const alias = parseProviderAliasPath(parsedPath);
const provider = buildProviderFromBuilder(params.opts);
return [
{
inputMode: "builder",
requestedPath: parsedPath,
setPath: parsedPath,
value: provider,
touchedProviderAlias: alias,
},
];
}
if (!pathProvided || !parsedPath) {
throw modeError("value/json mode requires <path> when batch mode is not used.");
}
if (params.value === undefined) {
throw modeError("value/json mode requires <value>.");
}
const parsedValue = parseValue(params.value, { strictJson });
return [
buildValueAssignmentOperation({
requestedPath: parsedPath,
value: parsedValue,
inputMode: modeResolution.mode === "json" ? "json" : "value",
}),
];
}
function collectDryRunRefs(params: {
config: OpenClawConfig;
operations: ConfigSetOperation[];
}): SecretRef[] {
const refsByKey = new Map<string, SecretRef>();
const targetPaths = new Set<string>();
const providerAliases = new Set<string>();
for (const operation of params.operations) {
if (operation.assignedRef) {
refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef);
}
if (operation.touchedSecretTargetPath) {
targetPaths.add(operation.touchedSecretTargetPath);
}
if (operation.touchedProviderAlias) {
providerAliases.add(operation.touchedProviderAlias);
}
}
if (targetPaths.size === 0 && providerAliases.size === 0) {
return [...refsByKey.values()];
}
const defaults = params.config.secrets?.defaults;
for (const target of discoverConfigSecretTargets(params.config)) {
const { ref } = resolveSecretInputRef({
value: target.value,
refValue: target.refValue,
defaults,
});
if (!ref) {
continue;
}
if (targetPaths.has(target.path) || providerAliases.has(ref.provider)) {
refsByKey.set(secretRefKey(ref), ref);
}
}
return [...refsByKey.values()];
}
async function collectDryRunResolvabilityErrors(params: {
refs: SecretRef[];
config: OpenClawConfig;
}): Promise<ConfigSetDryRunError[]> {
const failures: ConfigSetDryRunError[] = [];
for (const ref of params.refs) {
try {
await resolveSecretRefValue(ref, {
config: params.config,
env: process.env,
});
} catch (err) {
failures.push({
kind: "resolvability",
message: String(err),
ref: `${ref.source}:${ref.provider}:${ref.id}`,
});
}
}
return failures;
}
function collectDryRunSchemaErrors(config: OpenClawConfig): ConfigSetDryRunError[] {
const validated = validateConfigObjectRaw(config);
if (validated.ok) {
return [];
}
return formatConfigIssueLines(validated.issues, "-", { normalizeRoot: true }).map((message) => ({
kind: "schema",
message,
}));
}
function formatDryRunFailureMessage(errors: ConfigSetDryRunError[]): string {
const schemaErrors = errors.filter((error) => error.kind === "schema");
const resolveErrors = errors.filter((error) => error.kind === "resolvability");
const lines: string[] = [];
if (schemaErrors.length > 0) {
lines.push("Dry run failed: config schema validation failed.");
lines.push(...schemaErrors.map((error) => `- ${error.message}`));
}
if (resolveErrors.length > 0) {
lines.push(
`Dry run failed: ${resolveErrors.length} SecretRef assignment(s) could not be resolved.`,
);
lines.push(
...resolveErrors
.slice(0, 5)
.map((error) => `- ${error.ref ?? "<unknown-ref>"} -> ${error.message}`),
);
if (resolveErrors.length > 5) {
lines.push(`- ... ${resolveErrors.length - 5} more`);
}
}
return lines.join("\n");
}
export async function runConfigSet(opts: {
path?: string;
value?: string;
cliOptions: ConfigSetOptions;
runtime?: RuntimeEnv;
}) {
const runtime = opts.runtime ?? defaultRuntime;
try {
const hasBatchMode = Boolean(
(opts.cliOptions.batchJson && opts.cliOptions.batchJson.trim().length > 0) ||
(opts.cliOptions.batchFile && opts.cliOptions.batchFile.trim().length > 0),
);
const modeResolution = resolveConfigSetMode({
hasBatchMode,
hasRefBuilderOptions: hasRefBuilderOptions(opts.cliOptions),
hasProviderBuilderOptions: hasProviderBuilderOptions(opts.cliOptions),
strictJson: Boolean(opts.cliOptions.strictJson || opts.cliOptions.json),
});
if (!modeResolution.ok) {
throw modeError(modeResolution.error);
}
const batchEntries = parseBatchSource(opts.cliOptions);
if (batchEntries) {
if (opts.path !== undefined || opts.value !== undefined) {
throw modeError("batch mode does not accept <path> or <value> arguments.");
}
}
const operations = batchEntries
? parseBatchOperations(batchEntries)
: buildSingleSetOperations({
path: opts.path,
value: opts.value,
opts: opts.cliOptions,
});
const snapshot = await loadValidConfig(runtime);
// Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults)
// instead of snapshot.config (runtime-merged with defaults).
// This prevents runtime defaults from leaking into the written config file (issue #6070)
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
for (const operation of operations) {
ensureValidOllamaProviderForApiKeySet(next, operation.setPath);
setAtPath(next, operation.setPath, operation.value);
}
const nextConfig = next as OpenClawConfig;
if (opts.cliOptions.dryRun) {
const hasJsonMode = operations.some((operation) => operation.inputMode === "json");
const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder");
const refs =
hasJsonMode || hasBuilderMode
? collectDryRunRefs({
config: nextConfig,
operations,
})
: [];
const errors: ConfigSetDryRunError[] = [];
if (hasJsonMode) {
errors.push(...collectDryRunSchemaErrors(nextConfig));
}
if (hasJsonMode || hasBuilderMode) {
errors.push(
...(await collectDryRunResolvabilityErrors({
refs,
config: nextConfig,
})),
);
}
const dryRunResult: ConfigSetDryRunResult = {
ok: errors.length === 0,
operations: operations.length,
configPath: shortenHomePath(snapshot.path),
inputModes: [...new Set(operations.map((operation) => operation.inputMode))],
checks: {
schema: hasJsonMode,
resolvability: hasJsonMode || hasBuilderMode,
},
refsChecked: refs.length,
...(errors.length > 0 ? { errors } : {}),
};
if (errors.length > 0) {
if (opts.cliOptions.json) {
throw new ConfigSetDryRunValidationError(dryRunResult);
}
throw new Error(formatDryRunFailureMessage(errors));
}
if (opts.cliOptions.json) {
runtime.log(JSON.stringify(dryRunResult, null, 2));
} else {
runtime.log(
info(
`Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`,
),
);
}
return;
}
await writeConfigFile(next);
if (operations.length === 1) {
runtime.log(
info(
`Updated ${toDotPath(operations[0]?.requestedPath ?? [])}. Restart the gateway to apply.`,
),
);
return;
}
runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`));
} catch (err) {
if (
opts.cliOptions.dryRun &&
opts.cliOptions.json &&
err instanceof ConfigSetDryRunValidationError
) {
runtime.log(JSON.stringify(err.result, null, 2));
runtime.exit(1);
return;
}
runtime.error(danger(String(err)));
runtime.exit(1);
}
}
export async function runConfigGet(opts: { path: string; json?: boolean; runtime?: RuntimeEnv }) {
const runtime = opts.runtime ?? defaultRuntime;
try {
@ -425,30 +1121,72 @@ export function registerConfigCli(program: Command) {
cmd
.command("set")
.description("Set a config value by dot path")
.argument("<path>", "Config path (dot or bracket notation)")
.argument("<value>", "Value (JSON5 or raw string)")
.description(CONFIG_SET_DESCRIPTION)
.argument("[path]", "Config path (dot or bracket notation)")
.argument("[value]", "Value (JSON5 or raw string)")
.option("--strict-json", "Strict JSON5 parsing (error instead of raw string fallback)", false)
.option("--json", "Legacy alias for --strict-json", false)
.action(async (path: string, value: string, opts) => {
try {
const parsedPath = parseRequiredPath(path);
const parsedValue = parseValue(value, {
strictJson: Boolean(opts.strictJson || opts.json),
});
const snapshot = await loadValidConfig();
// Use snapshot.resolved (config after $include and ${ENV} resolution, but BEFORE runtime defaults)
// instead of snapshot.config (runtime-merged with defaults).
// This prevents runtime defaults from leaking into the written config file (issue #6070)
const next = structuredClone(snapshot.resolved) as Record<string, unknown>;
ensureValidOllamaProviderForApiKeySet(next, parsedPath);
setAtPath(next, parsedPath, parsedValue);
await writeConfigFile(next);
defaultRuntime.log(info(`Updated ${path}. Restart the gateway to apply.`));
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
}
.option("--dry-run", "Validate changes without writing openclaw.json", false)
.option("--ref-provider <alias>", "SecretRef builder: provider alias")
.option("--ref-source <source>", "SecretRef builder: source (env|file|exec)")
.option("--ref-id <id>", "SecretRef builder: ref id")
.option("--provider-source <source>", "Provider builder: source (env|file|exec)")
.option(
"--provider-allowlist <envVar>",
"Provider builder (env): allowlist entry (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option("--provider-path <path>", "Provider builder (file): path")
.option("--provider-mode <mode>", "Provider builder (file): mode (singleValue|json)")
.option("--provider-timeout-ms <ms>", "Provider builder (file|exec): timeout ms")
.option("--provider-max-bytes <bytes>", "Provider builder (file): max bytes")
.option("--provider-command <path>", "Provider builder (exec): absolute command path")
.option(
"--provider-arg <arg>",
"Provider builder (exec): command arg (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option("--provider-no-output-timeout-ms <ms>", "Provider builder (exec): no-output timeout ms")
.option("--provider-max-output-bytes <bytes>", "Provider builder (exec): max output bytes")
.option("--provider-json-only", "Provider builder (exec): require JSON output", false)
.option(
"--provider-env <key=value>",
"Provider builder (exec): env assignment (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option(
"--provider-pass-env <envVar>",
"Provider builder (exec): pass host env var (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option(
"--provider-trusted-dir <path>",
"Provider builder (exec): trusted directory (repeatable)",
(value: string, previous: string[]) => [...previous, value],
[] as string[],
)
.option(
"--provider-allow-insecure-path",
"Provider builder (exec): bypass strict path permission checks",
false,
)
.option(
"--provider-allow-symlink-command",
"Provider builder (exec): allow command symlink path",
false,
)
.option("--batch-json <json>", "Batch mode: JSON array of set operations")
.option("--batch-file <path>", "Batch mode: read JSON array of set operations from file")
.action(async (path: string | undefined, value: string | undefined, opts: ConfigSetOptions) => {
await runConfigSet({
path,
value,
cliOptions: opts,
});
});
cmd

View File

@ -0,0 +1,20 @@
export type ConfigSetDryRunInputMode = "value" | "json" | "builder";
export type ConfigSetDryRunError = {
kind: "schema" | "resolvability";
message: string;
ref?: string;
};
export type ConfigSetDryRunResult = {
ok: boolean;
operations: number;
configPath: string;
inputModes: ConfigSetDryRunInputMode[];
checks: {
schema: boolean;
resolvability: boolean;
};
refsChecked: number;
errors?: ConfigSetDryRunError[];
};

View File

@ -0,0 +1,113 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parseBatchSource } from "./config-set-input.js";
describe("config set input parsing", () => {
it("returns null when no batch options are provided", () => {
expect(parseBatchSource({})).toBeNull();
});
it("rejects using both --batch-json and --batch-file", () => {
expect(() =>
parseBatchSource({
batchJson: "[]",
batchFile: "/tmp/batch.json",
}),
).toThrow("Use either --batch-json or --batch-file, not both.");
});
it("parses valid --batch-json payloads", () => {
const parsed = parseBatchSource({
batchJson:
'[{"path":"gateway.auth.mode","value":"token"},{"path":"channels.discord.token","ref":{"source":"env","provider":"default","id":"DISCORD_BOT_TOKEN"}},{"path":"secrets.providers.default","provider":{"source":"env"}}]',
});
expect(parsed).toEqual([
{
path: "gateway.auth.mode",
value: "token",
},
{
path: "channels.discord.token",
ref: {
source: "env",
provider: "default",
id: "DISCORD_BOT_TOKEN",
},
},
{
path: "secrets.providers.default",
provider: {
source: "env",
},
},
]);
});
it("rejects malformed --batch-json payloads", () => {
expect(() =>
parseBatchSource({
batchJson: "{",
}),
).toThrow("Failed to parse --batch-json:");
});
it("rejects --batch-json payloads that are not arrays", () => {
expect(() =>
parseBatchSource({
batchJson: '{"path":"gateway.auth.mode","value":"token"}',
}),
).toThrow("--batch-json must be a JSON array.");
});
it("rejects batch entries without path", () => {
expect(() =>
parseBatchSource({
batchJson: '[{"value":"token"}]',
}),
).toThrow("--batch-json[0].path is required.");
});
it("rejects batch entries that do not contain exactly one mode key", () => {
expect(() =>
parseBatchSource({
batchJson: '[{"path":"gateway.auth.mode","value":"token","provider":{"source":"env"}}]',
}),
).toThrow("--batch-json[0] must include exactly one of: value, ref, provider.");
});
it("parses valid --batch-file payloads", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-"));
const batchPath = path.join(tempDir, "batch.json");
fs.writeFileSync(batchPath, '[{"path":"gateway.auth.mode","value":"token"}]', "utf8");
try {
const parsed = parseBatchSource({
batchFile: batchPath,
});
expect(parsed).toEqual([
{
path: "gateway.auth.mode",
value: "token",
},
]);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
it("rejects malformed --batch-file payloads", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-config-set-input-invalid-"));
const batchPath = path.join(tempDir, "batch.json");
fs.writeFileSync(batchPath, "{}", "utf8");
try {
expect(() =>
parseBatchSource({
batchFile: batchPath,
}),
).toThrow("--batch-file must be a JSON array.");
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
});

130
src/cli/config-set-input.ts Normal file
View File

@ -0,0 +1,130 @@
import fs from "node:fs";
import JSON5 from "json5";
export type ConfigSetOptions = {
strictJson?: boolean;
json?: boolean;
dryRun?: boolean;
refProvider?: string;
refSource?: string;
refId?: string;
providerSource?: string;
providerAllowlist?: string[];
providerPath?: string;
providerMode?: string;
providerTimeoutMs?: string;
providerMaxBytes?: string;
providerCommand?: string;
providerArg?: string[];
providerNoOutputTimeoutMs?: string;
providerMaxOutputBytes?: string;
providerJsonOnly?: boolean;
providerEnv?: string[];
providerPassEnv?: string[];
providerTrustedDir?: string[];
providerAllowInsecurePath?: boolean;
providerAllowSymlinkCommand?: boolean;
batchJson?: string;
batchFile?: string;
};
export type ConfigSetBatchEntry = {
path: string;
value?: unknown;
ref?: unknown;
provider?: unknown;
};
export function hasBatchMode(opts: ConfigSetOptions): boolean {
return Boolean(
(opts.batchJson && opts.batchJson.trim().length > 0) ||
(opts.batchFile && opts.batchFile.trim().length > 0),
);
}
export function hasRefBuilderOptions(opts: ConfigSetOptions): boolean {
return Boolean(opts.refProvider || opts.refSource || opts.refId);
}
export function hasProviderBuilderOptions(opts: ConfigSetOptions): boolean {
return Boolean(
opts.providerSource ||
opts.providerAllowlist?.length ||
opts.providerPath ||
opts.providerMode ||
opts.providerTimeoutMs ||
opts.providerMaxBytes ||
opts.providerCommand ||
opts.providerArg?.length ||
opts.providerNoOutputTimeoutMs ||
opts.providerMaxOutputBytes ||
opts.providerJsonOnly ||
opts.providerEnv?.length ||
opts.providerPassEnv?.length ||
opts.providerTrustedDir?.length ||
opts.providerAllowInsecurePath ||
opts.providerAllowSymlinkCommand,
);
}
function parseJson5Raw(raw: string, label: string): unknown {
try {
return JSON5.parse(raw);
} catch (err) {
throw new Error(`Failed to parse ${label}: ${String(err)}`, { cause: err });
}
}
function parseBatchEntries(raw: string, sourceLabel: string): ConfigSetBatchEntry[] {
const parsed = parseJson5Raw(raw, sourceLabel);
if (!Array.isArray(parsed)) {
throw new Error(`${sourceLabel} must be a JSON array.`);
}
const out: ConfigSetBatchEntry[] = [];
for (const [index, entry] of parsed.entries()) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
throw new Error(`${sourceLabel}[${index}] must be an object.`);
}
const typed = entry as Record<string, unknown>;
const path = typeof typed.path === "string" ? typed.path.trim() : "";
if (!path) {
throw new Error(`${sourceLabel}[${index}].path is required.`);
}
const hasValue = Object.prototype.hasOwnProperty.call(typed, "value");
const hasRef = Object.prototype.hasOwnProperty.call(typed, "ref");
const hasProvider = Object.prototype.hasOwnProperty.call(typed, "provider");
const modeCount = Number(hasValue) + Number(hasRef) + Number(hasProvider);
if (modeCount !== 1) {
throw new Error(
`${sourceLabel}[${index}] must include exactly one of: value, ref, provider.`,
);
}
out.push({
path,
...(hasValue ? { value: typed.value } : {}),
...(hasRef ? { ref: typed.ref } : {}),
...(hasProvider ? { provider: typed.provider } : {}),
});
}
return out;
}
export function parseBatchSource(opts: ConfigSetOptions): ConfigSetBatchEntry[] | null {
const hasInline = Boolean(opts.batchJson && opts.batchJson.trim().length > 0);
const hasFile = Boolean(opts.batchFile && opts.batchFile.trim().length > 0);
if (!hasInline && !hasFile) {
return null;
}
if (hasInline && hasFile) {
throw new Error("Use either --batch-json or --batch-file, not both.");
}
if (hasInline) {
return parseBatchEntries(opts.batchJson as string, "--batch-json");
}
const pathname = (opts.batchFile as string).trim();
if (!pathname) {
throw new Error("--batch-file must not be empty.");
}
const raw = fs.readFileSync(pathname, "utf8");
return parseBatchEntries(raw, "--batch-file");
}

View File

@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { resolveConfigSetMode } from "./config-set-parser.js";
describe("resolveConfigSetMode", () => {
it("selects value mode by default", () => {
const result = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: false,
hasProviderBuilderOptions: false,
strictJson: false,
});
expect(result).toEqual({ ok: true, mode: "value" });
});
it("selects json mode when strict parsing is enabled", () => {
const result = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: false,
hasProviderBuilderOptions: false,
strictJson: true,
});
expect(result).toEqual({ ok: true, mode: "json" });
});
it("selects ref-builder mode when ref flags are present", () => {
const result = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: true,
hasProviderBuilderOptions: false,
strictJson: false,
});
expect(result).toEqual({ ok: true, mode: "ref_builder" });
});
it("selects provider-builder mode when provider flags are present", () => {
const result = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: false,
hasProviderBuilderOptions: true,
strictJson: false,
});
expect(result).toEqual({ ok: true, mode: "provider_builder" });
});
it("returns batch mode when batch flags are present", () => {
const result = resolveConfigSetMode({
hasBatchMode: true,
hasRefBuilderOptions: false,
hasProviderBuilderOptions: false,
strictJson: false,
});
expect(result).toEqual({ ok: true, mode: "batch" });
});
it("rejects ref-builder and provider-builder collisions", () => {
const result = resolveConfigSetMode({
hasBatchMode: false,
hasRefBuilderOptions: true,
hasProviderBuilderOptions: true,
strictJson: false,
});
expect(result.ok).toBe(false);
expect(result).toMatchObject({
error: expect.stringContaining("choose exactly one mode"),
});
});
it("rejects mixing batch mode with builder flags", () => {
const result = resolveConfigSetMode({
hasBatchMode: true,
hasRefBuilderOptions: true,
hasProviderBuilderOptions: false,
strictJson: false,
});
expect(result.ok).toBe(false);
expect(result).toMatchObject({
error: expect.stringContaining("batch mode (--batch-json/--batch-file) cannot be combined"),
});
});
});

View File

@ -0,0 +1,43 @@
export type ConfigSetMode = "value" | "json" | "ref_builder" | "provider_builder" | "batch";
export type ConfigSetModeResolution =
| {
ok: true;
mode: ConfigSetMode;
}
| {
ok: false;
error: string;
};
export function resolveConfigSetMode(params: {
hasBatchMode: boolean;
hasRefBuilderOptions: boolean;
hasProviderBuilderOptions: boolean;
strictJson: boolean;
}): ConfigSetModeResolution {
if (params.hasBatchMode) {
if (params.hasRefBuilderOptions || params.hasProviderBuilderOptions) {
return {
ok: false,
error:
"batch mode (--batch-json/--batch-file) cannot be combined with ref builder (--ref-*) or provider builder (--provider-*) flags.",
};
}
return { ok: true, mode: "batch" };
}
if (params.hasRefBuilderOptions && params.hasProviderBuilderOptions) {
return {
ok: false,
error:
"choose exactly one mode: ref builder (--ref-provider/--ref-source/--ref-id) or provider builder (--provider-*), not both.",
};
}
if (params.hasRefBuilderOptions) {
return { ok: true, mode: "ref_builder" };
}
if (params.hasProviderBuilderOptions) {
return { ok: true, mode: "provider_builder" };
}
return { ok: true, mode: params.strictJson ? "json" : "value" };
}

View File

@ -239,6 +239,24 @@ export function resolvePlanTargetAgainstRegistry(candidate: {
return null;
}
export function resolveConfigSecretTargetByPath(pathSegments: string[]): ResolvedPlanTarget | null {
for (const entry of OPENCLAW_COMPILED_SECRET_TARGETS) {
if (!entry.includeInPlan) {
continue;
}
const matched = matchPathTokens(pathSegments, entry.pathTokens);
if (!matched) {
continue;
}
const resolved = toResolvedPlanTarget(entry, pathSegments, matched.captures);
if (!resolved) {
continue;
}
return resolved;
}
return null;
}
export function discoverConfigSecretTargets(
config: OpenClawConfig,
): DiscoveredConfigSecretTarget[] {

View File

@ -3,7 +3,10 @@ import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { buildSecretRefCredentialMatrix } from "./credential-matrix.js";
import { discoverConfigSecretTargetsByIds } from "./target-registry.js";
import {
discoverConfigSecretTargetsByIds,
resolveConfigSecretTargetByPath,
} from "./target-registry.js";
describe("secret target registry", () => {
it("stays in sync with docs/reference/secretref-user-supplied-credentials-matrix.json", () => {
@ -96,4 +99,15 @@ describe("secret target registry", () => {
expect(targets[0]?.entry.id).toBe("talk.apiKey");
expect(targets[0]?.path).toBe("talk.apiKey");
});
it("resolves config targets by exact path including sibling ref metadata", () => {
const target = resolveConfigSecretTargetByPath(["channels", "googlechat", "serviceAccount"]);
expect(target).not.toBeNull();
expect(target?.entry.id).toBe("channels.googlechat.serviceAccount");
expect(target?.refPathSegments).toEqual(["channels", "googlechat", "serviceAccountRef"]);
});
it("returns null when no config target path matches", () => {
expect(resolveConfigSecretTargetByPath(["gateway", "auth", "mode"])).toBeNull();
});
});