diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 2164ad7002f..3c835775764 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -108,8 +108,8 @@ cat > discord.patch.json5 <<'JSON5' }, } JSON5 -openclaw config apply --file ./discord.patch.json5 --dry-run -openclaw config apply --file ./discord.patch.json5 +openclaw config patch --file ./discord.patch.json5 --dry-run +openclaw config patch --file ./discord.patch.json5 openclaw gateway ``` @@ -150,7 +150,7 @@ openclaw gateway DISCORD_BOT_TOKEN=... ``` - For scripted or remote setup, write the same JSON5 block with `openclaw config apply --file ./discord.patch.json5 --dry-run` and then rerun without `--dry-run`. Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets). + For scripted or remote setup, write the same JSON5 block with `openclaw config patch --file ./discord.patch.json5 --dry-run` and then rerun without `--dry-run`. Plaintext `token` values are supported. SecretRef values are also supported for `channels.discord.token` across env/file/exec providers. See [Secrets Management](/gateway/secrets). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index 8a431468a03..e30ba835324 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -53,8 +53,8 @@ cat > slack.socket.patch.json5 <<'JSON5' }, } JSON5 -openclaw config apply --file ./slack.socket.patch.json5 --dry-run -openclaw config apply --file ./slack.socket.patch.json5 +openclaw config patch --file ./slack.socket.patch.json5 --dry-run +openclaw config patch --file ./slack.socket.patch.json5 ``` Env fallback (default account only): @@ -109,8 +109,8 @@ cat > slack.http.patch.json5 <<'JSON5' }, } JSON5 -openclaw config apply --file ./slack.http.patch.json5 --dry-run -openclaw config apply --file ./slack.http.patch.json5 +openclaw config patch --file ./slack.http.patch.json5 --dry-run +openclaw config patch --file ./slack.http.patch.json5 ``` diff --git a/docs/cli/config.md b/docs/cli/config.md index d465d7d39a7..bf3079f21d7 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -1,12 +1,12 @@ --- -summary: "CLI reference for `openclaw config` (get/set/apply/unset/file/schema/validate)" +summary: "CLI reference for `openclaw config` (get/set/patch/unset/file/schema/validate)" read_when: - You want to read or edit config non-interactively title: "Config" sidebarTitle: "Config" --- -Config helpers for non-interactive edits in `openclaw.json`: get/set/apply/unset/file/schema/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/patch/unset/file/schema/validate values by path and print the active config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`). ## Root options @@ -31,7 +31,7 @@ openclaw config set agents.list[0].tools.exec.node "node-id-or-name" openclaw config set agents.defaults.models '{"openai/gpt-5.4":{}}' --strict-json --merge 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 apply --file ./openclaw.patch.json5 --dry-run +openclaw config patch --file ./openclaw.patch.json5 --dry-run openclaw config unset plugins.entries.brave.config.webSearch.apiKey openclaw config set channels.discord.token --ref-provider default --ref-source env --ref-id DISCORD_BOT_TOKEN --dry-run openclaw config validate @@ -166,20 +166,20 @@ SecretRef assignments are rejected on unsupported runtime-mutable surfaces (for 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. -## `config apply` +## `config patch` -Use `config apply` when you want to paste or pipe a config-shaped patch instead of running many path-based `config set` commands. The input is a JSON5 object. Objects merge recursively, arrays and scalar values replace the target value, and `null` deletes the target path. +Use `config patch` when you want to paste or pipe a config-shaped patch instead of running many path-based `config set` commands. The input is a JSON5 object. Objects merge recursively, arrays and scalar values replace the target value, and `null` deletes the target path. ```bash -openclaw config apply --file ./openclaw.patch.json5 --dry-run -openclaw config apply --file ./openclaw.patch.json5 +openclaw config patch --file ./openclaw.patch.json5 --dry-run +openclaw config patch --file ./openclaw.patch.json5 ``` You can also pipe a patch over stdin, which is useful for remote setup scripts: ```bash -ssh openclaw-host 'openclaw config apply --stdin --dry-run' < ./openclaw.patch.json5 -ssh openclaw-host 'openclaw config apply --stdin' < ./openclaw.patch.json5 +ssh openclaw-host 'openclaw config patch --stdin --dry-run' < ./openclaw.patch.json5 +ssh openclaw-host 'openclaw config patch --stdin' < ./openclaw.patch.json5 ``` Example patch: @@ -217,7 +217,7 @@ Example patch: Use `--replace-path ` when one object or array must become exactly the provided value instead of being recursively patched: ```bash -openclaw config apply --file ./discord.patch.json5 --replace-path 'channels.discord.guilds["123"].channels' +openclaw config patch --file ./discord.patch.json5 --replace-path 'channels.discord.guilds["123"].channels' ``` `--dry-run` runs schema and SecretRef resolvability checks without writing. Exec-backed SecretRefs are skipped by default during dry-run; add `--allow-exec` when you intentionally want dry-run to execute provider commands. diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index 37fa9437781..ba23ae4920a 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -115,7 +115,7 @@ Approve devices with `openclaw devices list` and `openclaw devices approve .exe.xyz 'openclaw config apply --stdin --dry-run' < ./openclaw.remote.patch.json5 -ssh .exe.xyz 'openclaw config apply --stdin' < ./openclaw.remote.patch.json5 +ssh .exe.xyz 'openclaw config patch --stdin --dry-run' < ./openclaw.remote.patch.json5 +ssh .exe.xyz 'openclaw config patch --stdin' < ./openclaw.remote.patch.json5 ssh .exe.xyz 'openclaw gateway restart && openclaw health' ``` Use `--replace-path` when a nested allowlist should become exactly the patch value, for example when replacing a Discord channel allowlist: ```bash -ssh .exe.xyz 'openclaw config apply --stdin --replace-path "channels.discord.guilds[\"123\"].channels"' < ./discord.patch.json5 +ssh .exe.xyz 'openclaw config patch --stdin --replace-path "channels.discord.guilds[\"123\"].channels"' < ./discord.patch.json5 ``` ## Remote access diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index b1f5bb071ea..8713b680f43 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1384,7 +1384,7 @@ describe("config cli", () => { ); }); - it("applies a config patch object in one write", async () => { + it("patches config from one object in one write", async () => { const resolved = { secrets: { providers: { @@ -1403,7 +1403,7 @@ describe("config cli", () => { const pathname = path.join( os.tmpdir(), - `openclaw-config-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + `openclaw-config-patch-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, ); fs.writeFileSync( pathname, @@ -1435,7 +1435,7 @@ describe("config cli", () => { "utf8", ); try { - await runConfigCommand(["config", "apply", "--file", pathname]); + await runConfigCommand(["config", "patch", "--file", pathname]); } finally { fs.rmSync(pathname, { force: true }); } @@ -1462,7 +1462,7 @@ describe("config cli", () => { ).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }); }); - it("dry-runs config apply and resolves changed SecretRefs", async () => { + it("dry-runs config patch and resolves changed SecretRefs", async () => { const resolved = { secrets: { providers: { @@ -1474,7 +1474,7 @@ describe("config cli", () => { const pathname = path.join( os.tmpdir(), - `openclaw-config-apply-dry-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + `openclaw-config-patch-dry-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, ); fs.writeFileSync( pathname, @@ -1488,7 +1488,7 @@ describe("config cli", () => { "utf8", ); try { - await runConfigCommand(["config", "apply", "--file", pathname, "--dry-run"]); + await runConfigCommand(["config", "patch", "--file", pathname, "--dry-run"]); } finally { fs.rmSync(pathname, { force: true }); } @@ -1501,7 +1501,7 @@ describe("config cli", () => { ); }); - it("dry-runs nested SecretRefs inside config apply replacements", async () => { + it("dry-runs nested SecretRefs inside config patch replacements", async () => { const resolved = { secrets: { providers: { @@ -1519,7 +1519,7 @@ describe("config cli", () => { const pathname = path.join( os.tmpdir(), - `openclaw-config-apply-nested-ref-${Date.now()}-${Math.random() + `openclaw-config-patch-nested-ref-${Date.now()}-${Math.random() .toString(16) .slice(2)}.json5`, ); @@ -1541,7 +1541,7 @@ describe("config cli", () => { await expect( runConfigCommand([ "config", - "apply", + "patch", "--file", pathname, "--replace-path", @@ -1560,17 +1560,17 @@ describe("config cli", () => { ); }); - it("rejects config apply --json without dry-run", async () => { - await expect(runConfigCommand(["config", "apply", "--stdin", "--json"])).rejects.toThrow( + it("rejects config patch --json without dry-run", async () => { + await expect(runConfigCommand(["config", "patch", "--stdin", "--json"])).rejects.toThrow( "__exit__:1", ); expect(mockError).toHaveBeenCalledWith( - expect.stringContaining("config apply mode error: --json requires --dry-run."), + expect.stringContaining("config patch mode error: --json requires --dry-run."), ); expect(mockWriteConfigFile).not.toHaveBeenCalled(); }); - it("supports replace-path and null deletes in config apply", async () => { + it("supports replace-path and null deletes in config patch", async () => { const resolved = { channels: { slack: { @@ -1591,7 +1591,7 @@ describe("config cli", () => { const pathname = path.join( os.tmpdir(), - `openclaw-config-apply-replace-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + `openclaw-config-patch-replace-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, ); fs.writeFileSync( pathname, @@ -1616,7 +1616,7 @@ describe("config cli", () => { try { await runConfigCommand([ "config", - "apply", + "patch", "--file", pathname, "--replace-path", @@ -1641,6 +1641,47 @@ describe("config cli", () => { }); }); + it("rejects unused config patch replace paths", async () => { + const pathname = path.join( + os.tmpdir(), + `openclaw-config-patch-unused-replace-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}.json5`, + ); + fs.writeFileSync( + pathname, + JSON.stringify({ + channels: { + discord: { + enabled: true, + }, + }, + }), + "utf8", + ); + try { + await expect( + runConfigCommand([ + "config", + "patch", + "--file", + pathname, + "--replace-path", + "channels.discord.guilds", + ]), + ).rejects.toThrow("__exit__:1"); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining( + "config patch mode error: --replace-path channels.discord.guilds did not match any value in the input patch.", + ), + ); + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + }); + it("rejects malformed batch entries with mixed operation keys", async () => { await expect( runConfigCommand([ diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 7cf6d12e946..ede3f0528ac 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -74,7 +74,7 @@ type ConfigSetOperation = { touchedProviderAlias?: string; assignedRef?: SecretRef; }; -type ConfigApplyOptions = { +type ConfigPatchOptions = { file?: string | undefined; stdin?: boolean | undefined; dryRun?: boolean | undefined; @@ -105,10 +105,10 @@ const CONFIG_SET_EXAMPLE_PROVIDER = formatCliCommand( const CONFIG_SET_EXAMPLE_BATCH = formatCliCommand( "openclaw config set --batch-file ./config-set.batch.json --dry-run", ); -const CONFIG_APPLY_EXAMPLE_FILE = formatCliCommand( - "openclaw config apply --file ./openclaw.patch.json5 --dry-run", +const CONFIG_PATCH_EXAMPLE_FILE = formatCliCommand( + "openclaw config patch --file ./openclaw.patch.json5 --dry-run", ); -const CONFIG_APPLY_EXAMPLE_STDIN = formatCliCommand("openclaw config apply --stdin"); +const CONFIG_PATCH_EXAMPLE_STDIN = formatCliCommand("openclaw config patch --stdin"); const CONFIG_SET_DESCRIPTION = [ "Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).", "Examples:", @@ -117,14 +117,15 @@ const CONFIG_SET_DESCRIPTION = [ CONFIG_SET_EXAMPLE_PROVIDER, CONFIG_SET_EXAMPLE_BATCH, ].join("\n"); -const CONFIG_APPLY_DESCRIPTION = [ - "Apply a JSON5 config patch object in one validated write.", +const CONFIG_PATCH_DESCRIPTION = [ + "Patch config from a JSON5 object in one validated write.", "Objects merge recursively, arrays/scalars replace, and null deletes a path.", "Examples:", - CONFIG_APPLY_EXAMPLE_FILE, - CONFIG_APPLY_EXAMPLE_STDIN, + CONFIG_PATCH_EXAMPLE_FILE, + CONFIG_PATCH_EXAMPLE_STDIN, ].join("\n"); const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5; +const CONFIG_PATCH_STDIN_MAX_BYTES = 1024 * 1024; class ConfigSetDryRunValidationError extends Error { constructor(readonly result: ConfigSetDryRunResult) { @@ -908,24 +909,37 @@ function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperatio return operations; } -function configApplyModeError(message: string): Error { - return new Error(`config apply mode error: ${message}`); +function configPatchModeError(message: string): Error { + return new Error(`config patch mode error: ${message}`); } async function readStdinText(): Promise { let raw = ""; + let bytes = 0; + if (process.stdin.isTTY) { + throw configPatchModeError( + "--stdin refuses to read from an interactive terminal; pipe input or use --file .", + ); + } process.stdin.setEncoding("utf8"); for await (const chunk of process.stdin) { - raw += String(chunk); + const text = String(chunk); + bytes += Buffer.byteLength(text, "utf8"); + if (bytes > CONFIG_PATCH_STDIN_MAX_BYTES) { + throw configPatchModeError( + `--stdin input exceeds ${CONFIG_PATCH_STDIN_MAX_BYTES} bytes; use --file for larger patches.`, + ); + } + raw += text; } return raw; } -async function readConfigApplyPatch(opts: ConfigApplyOptions): Promise { +async function readConfigPatchInput(opts: ConfigPatchOptions): Promise { const file = normalizeOptionalString(opts.file); const stdin = Boolean(opts.stdin); if (Boolean(file) === stdin) { - throw configApplyModeError("provide exactly one of --file or --stdin."); + throw configPatchModeError("provide exactly one of --file or --stdin."); } const sourceLabel = stdin ? "--stdin" : "--file"; const raw = stdin ? await readStdinText() : fs.readFileSync(file as string, "utf8"); @@ -940,8 +954,8 @@ function parseReplacePaths(paths: string[] | undefined): PathSegment[][] { return (paths ?? []).map((path) => parseRequiredPath(path)); } -function matchesAnyPath(path: PathSegment[], candidates: PathSegment[][]): boolean { - return candidates.some((candidate) => pathEquals(path, candidate)); +function pathKey(path: PathSegment[]): string { + return JSON.stringify(path); } function buildDeleteOperation(path: PathSegment[]): ConfigSetOperation { @@ -980,17 +994,21 @@ function buildApplyValueOperation(params: { }; } -function buildConfigApplyOperations(params: { +function buildConfigPatchOperations(params: { patch: unknown; replacePaths: PathSegment[][]; }): ConfigSetOperation[] { if (!isPlainRecord(params.patch)) { - throw configApplyModeError("input must be a JSON5 object patch."); + throw configPatchModeError("input must be a JSON5 object patch."); } const operations: ConfigSetOperation[] = []; + const replacePathKeys = new Set(params.replacePaths.map(pathKey)); + const matchedReplacePathKeys = new Set(); const visit = (value: unknown, path: PathSegment[]) => { validatePathSegments(path); - if (path.length > 0 && matchesAnyPath(path, params.replacePaths)) { + const replacementKey = pathKey(path); + if (path.length > 0 && replacePathKeys.has(replacementKey)) { + matchedReplacePathKeys.add(replacementKey); operations.push( value === null ? buildDeleteOperation(path) @@ -1013,14 +1031,22 @@ function buildConfigApplyOperations(params: { return; } if (path.length === 0) { - throw configApplyModeError("input must contain at least one config key."); + throw configPatchModeError("input must contain at least one config key."); } operations.push(buildApplyValueOperation({ path, value })); }; visit(params.patch, []); + const unusedReplacePath = params.replacePaths.find( + (path) => !matchedReplacePathKeys.has(pathKey(path)), + ); + if (unusedReplacePath) { + throw configPatchModeError( + `--replace-path ${toDotPath(unusedReplacePath)} did not match any value in the input patch.`, + ); + } if (operations.length === 0) { - throw configApplyModeError("input patch did not contain any config updates."); + throw configPatchModeError("input patch did not contain any config updates."); } return operations; } @@ -1588,20 +1614,20 @@ export async function runConfigSet(opts: { } } -export async function runConfigApply(opts: { - cliOptions: ConfigApplyOptions; +export async function runConfigPatch(opts: { + cliOptions: ConfigPatchOptions; runtime?: RuntimeEnv; }) { const runtime = opts.runtime ?? defaultRuntime; try { if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { - throw configApplyModeError("--allow-exec requires --dry-run."); + throw configPatchModeError("--allow-exec requires --dry-run."); } if (opts.cliOptions.json && !opts.cliOptions.dryRun) { - throw configApplyModeError("--json requires --dry-run."); + throw configPatchModeError("--json requires --dry-run."); } - const patch = await readConfigApplyPatch(opts.cliOptions); - const operations = buildConfigApplyOperations({ + const patch = await readConfigPatchInput(opts.cliOptions); + const operations = buildConfigPatchOperations({ patch, replacePaths: parseReplacePaths(opts.cliOptions.replacePath), }); @@ -1880,8 +1906,8 @@ export function registerConfigCli(program: Command) { }); cmd - .command("apply") - .description(CONFIG_APPLY_DESCRIPTION) + .command("patch") + .description(CONFIG_PATCH_DESCRIPTION) .option("--file ", "Read a JSON5 config patch object from file") .option("--stdin", "Read a JSON5 config patch object from stdin", false) .option( @@ -1901,8 +1927,8 @@ export function registerConfigCli(program: Command) { (value: string, previous: string[]) => [...previous, value], [] as string[], ) - .action(async (opts: ConfigApplyOptions) => { - await runConfigApply({ cliOptions: opts }); + .action(async (opts: ConfigPatchOptions) => { + await runConfigPatch({ cliOptions: opts }); }); cmd