From 48a01798b036f7f607f0164b018b79652d7cd6b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 15:59:26 +0100 Subject: [PATCH] feat: add config apply patch command --- docs/channels/discord.md | 17 +- docs/channels/slack.md | 28 +- docs/cli/config.md | 61 +++- docs/install/exe-dev.md | 65 ++++ src/cli/config-cli.test.ts | 257 ++++++++++++++++ src/cli/config-cli.ts | 600 ++++++++++++++++++++++++++----------- 6 files changed, 848 insertions(+), 180 deletions(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 8f228f556c6..2164ad7002f 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -98,9 +98,18 @@ You will need to create a new application with a bot, add the bot to your server ```bash 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 +cat > discord.patch.json5 <<'JSON5' +{ + channels: { + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, +} +JSON5 +openclaw config apply --file ./discord.patch.json5 --dry-run +openclaw config apply --file ./discord.patch.json5 openclaw gateway ``` @@ -141,7 +150,7 @@ openclaw gateway DISCORD_BOT_TOKEN=... ``` - 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 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). diff --git a/docs/channels/slack.md b/docs/channels/slack.md index aef6348183e..8a431468a03 100644 --- a/docs/channels/slack.md +++ b/docs/channels/slack.md @@ -36,17 +36,25 @@ Production-ready for DMs and channels via Slack app integrations. Default mode i -```json5 + Recommended SecretRef setup: + +```bash +export SLACK_APP_TOKEN=xapp-... +export SLACK_BOT_TOKEN=xoxb-... +cat > slack.socket.patch.json5 <<'JSON5' { channels: { slack: { enabled: true, mode: "socket", - appToken: "xapp-...", - botToken: "xoxb-...", + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, }, }, } +JSON5 +openclaw config apply --file ./slack.socket.patch.json5 --dry-run +openclaw config apply --file ./slack.socket.patch.json5 ``` Env fallback (default account only): @@ -83,18 +91,26 @@ openclaw gateway -```json5 + Recommended SecretRef setup: + +```bash +export SLACK_BOT_TOKEN=xoxb-... +export SLACK_SIGNING_SECRET=... +cat > slack.http.patch.json5 <<'JSON5' { channels: { slack: { enabled: true, mode: "http", - botToken: "xoxb-...", - signingSecret: "your-signing-secret", + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + signingSecret: { source: "env", provider: "default", id: "SLACK_SIGNING_SECRET" }, webhookPath: "/slack/events", }, }, } +JSON5 +openclaw config apply --file ./slack.http.patch.json5 --dry-run +openclaw config apply --file ./slack.http.patch.json5 ``` diff --git a/docs/cli/config.md b/docs/cli/config.md index 1865fa9c412..d465d7d39a7 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -1,12 +1,12 @@ --- -summary: "CLI reference for `openclaw config` (get/set/unset/file/schema/validate)" +summary: "CLI reference for `openclaw config` (get/set/apply/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/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/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`). ## Root options @@ -31,6 +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 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 @@ -165,6 +166,62 @@ 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` + +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. + +```bash +openclaw config apply --file ./openclaw.patch.json5 --dry-run +openclaw config apply --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 +``` + +Example patch: + +```json5 +{ + channels: { + slack: { + enabled: true, + mode: "socket", + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + groupPolicy: "open", + requireMention: false, + }, + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + dmPolicy: "disabled", + dm: { enabled: false }, + groupPolicy: "allowlist", + }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { params: { fastMode: true } }, + }, + }, + }, +} +``` + +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' +``` + +`--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. + JSON path/value mode remains supported for both SecretRefs and providers: ```bash diff --git a/docs/install/exe-dev.md b/docs/install/exe-dev.md index 4bdb0483645..37fa9437781 100644 --- a/docs/install/exe-dev.md +++ b/docs/install/exe-dev.md @@ -113,6 +113,71 @@ with `openclaw config get gateway.auth.token` (or generate one with `openclaw do If you changed the gateway to password auth, use `gateway.auth.password` / `OPENCLAW_GATEWAY_PASSWORD` instead. Approve devices with `openclaw devices list` and `openclaw devices approve `. When in doubt, use Shelley from your browser! +## Remote channel setup + +For remote hosts, prefer one `config apply` patch over many SSH calls to `config set`. Keep real tokens in the VM environment or `~/.openclaw/.env`, and put only SecretRefs in `openclaw.json`. + +On the VM, make the service environment contain the secrets it needs: + +```bash +cat >> ~/.openclaw/.env <<'EOF' +SLACK_BOT_TOKEN=xoxb-... +SLACK_APP_TOKEN=xapp-... +DISCORD_BOT_TOKEN=... +OPENAI_API_KEY=sk-... +EOF +``` + +From your local machine, create a patch file and pipe it to the VM: + +```json5 +// openclaw.remote.patch.json5 +{ + secrets: { + providers: { + default: { source: "env" }, + }, + }, + channels: { + slack: { + enabled: true, + mode: "socket", + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + groupPolicy: "open", + requireMention: false, + }, + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + dmPolicy: "disabled", + dm: { enabled: false }, + groupPolicy: "allowlist", + }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { params: { fastMode: true } }, + }, + }, + }, +} +``` + +```bash +ssh .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 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 +``` + ## Remote access Remote access is handled by [exe.dev](https://exe.dev)'s authentication. By diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index a6ac6067c5e..b1f5bb071ea 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -1384,6 +1384,263 @@ describe("config cli", () => { ); }); + it("applies a config patch object in one write", async () => { + const resolved = { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "GPT 5.4" }, + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + ); + fs.writeFileSync( + pathname, + JSON.stringify({ + channels: { + slack: { + enabled: true, + mode: "socket", + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + groupPolicy: "open", + requireMention: false, + }, + discord: { + enabled: true, + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + groupPolicy: "allowlist", + }, + }, + agents: { + defaults: { + model: { primary: "openai/gpt-5.5" }, + models: { + "openai/gpt-5.5": { params: { fastMode: true } }, + }, + }, + }, + }), + "utf8", + ); + try { + await runConfigCommand(["config", "apply", "--file", pathname]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).toHaveBeenCalledTimes(1); + const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record; + expect( + ((written.agents as Record).defaults as Record).models, + ).toEqual({ + "openai/gpt-5.4": { alias: "GPT 5.4" }, + "openai/gpt-5.5": { params: { fastMode: true } }, + }); + expect( + ( + ((written.agents as Record).defaults as Record) + .model as Record + ).primary, + ).toBe("openai/gpt-5.5"); + expect( + ((written.channels as Record).slack as Record).botToken, + ).toEqual({ source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }); + expect( + ((written.channels as Record).discord as Record).token, + ).toEqual({ source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }); + }); + + it("dry-runs config apply and resolves changed SecretRefs", async () => { + const resolved = { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-apply-dry-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + ); + fs.writeFileSync( + pathname, + JSON.stringify({ + channels: { + discord: { + token: { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + }, + }, + }), + "utf8", + ); + try { + await runConfigCommand(["config", "apply", "--file", pathname, "--dry-run"]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(1); + expect(mockResolveSecretRefValue).toHaveBeenCalledWith( + { source: "env", provider: "default", id: "DISCORD_BOT_TOKEN" }, + expect.any(Object), + ); + }); + + it("dry-runs nested SecretRefs inside config apply replacements", async () => { + const resolved = { + secrets: { + providers: { + default: { source: "env" }, + }, + }, + channels: { + slack: { + enabled: false, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + mockResolveSecretRefValue.mockRejectedValue(new Error("missing env var")); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-apply-nested-ref-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}.json5`, + ); + fs.writeFileSync( + pathname, + JSON.stringify({ + channels: { + slack: { + enabled: true, + mode: "socket", + botToken: { source: "env", provider: "default", id: "SLACK_BOT_TOKEN" }, + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + }, + }, + }), + "utf8", + ); + try { + await expect( + runConfigCommand([ + "config", + "apply", + "--file", + pathname, + "--replace-path", + "channels.slack", + "--dry-run", + ]), + ).rejects.toThrow("__exit__:1"); + } finally { + fs.rmSync(pathname, { force: true }); + } + + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + expect(mockResolveSecretRefValue).toHaveBeenCalledTimes(2); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("Dry run failed: 2 SecretRef assignment(s) could not be resolved."), + ); + }); + + it("rejects config apply --json without dry-run", async () => { + await expect(runConfigCommand(["config", "apply", "--stdin", "--json"])).rejects.toThrow( + "__exit__:1", + ); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("config apply mode error: --json requires --dry-run."), + ); + expect(mockWriteConfigFile).not.toHaveBeenCalled(); + }); + + it("supports replace-path and null deletes in config apply", async () => { + const resolved = { + channels: { + slack: { + appToken: { source: "env", provider: "default", id: "SLACK_APP_TOKEN" }, + }, + discord: { + guilds: { + guild: { + channels: { + old: { enabled: true }, + }, + }, + }, + }, + }, + } as unknown as OpenClawConfig; + setSnapshot(resolved, resolved); + + const pathname = path.join( + os.tmpdir(), + `openclaw-config-apply-replace-${Date.now()}-${Math.random().toString(16).slice(2)}.json5`, + ); + fs.writeFileSync( + pathname, + JSON.stringify({ + channels: { + slack: { + appToken: null, + }, + discord: { + guilds: { + guild: { + channels: { + maintainers: { enabled: true, requireMention: true }, + }, + }, + }, + }, + }, + }), + "utf8", + ); + try { + await runConfigCommand([ + "config", + "apply", + "--file", + pathname, + "--replace-path", + "channels.discord.guilds.guild.channels", + ]); + } finally { + fs.rmSync(pathname, { force: true }); + } + + const written = mockWriteConfigFile.mock.calls[0]?.[0] as Record; + const channels = (written.channels as Record).discord as Record< + string, + unknown + >; + expect( + ((channels.guilds as Record).guild as Record) + .channels as Record, + ).toEqual({ maintainers: { enabled: true, requireMention: true } }); + expect((written.channels as Record).slack).not.toHaveProperty("appToken"); + expect(mockWriteConfigFile.mock.calls[0]?.[1]).toEqual({ + unsetPaths: [["channels", "slack", "appToken"]], + }); + }); + 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 16e786ca0d2..7cf6d12e946 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import type { Command } from "commander"; import JSON5 from "json5"; import { readConfigFileSnapshot, replaceConfigFile } from "../config/config.js"; @@ -67,11 +68,27 @@ type ConfigSetOperation = { requestedPath: PathSegment[]; setPath: PathSegment[]; value: unknown; + mutation?: "set" | "merge" | "replace" | "delete"; schemaValidated?: boolean; touchedSecretTargetPath?: string; touchedProviderAlias?: string; assignedRef?: SecretRef; }; +type ConfigApplyOptions = { + file?: string | undefined; + stdin?: boolean | undefined; + dryRun?: boolean | undefined; + allowExec?: boolean | undefined; + json?: boolean | undefined; + replacePath?: string[] | undefined; +}; +type ConfigMutationOptions = { + dryRun?: boolean | undefined; + allowExec?: boolean | undefined; + json?: boolean | undefined; + merge?: boolean | undefined; + replace?: boolean | undefined; +}; const GATEWAY_AUTH_MODE_PATH: PathSegment[] = ["gateway", "auth", "mode"]; const SECRET_PROVIDER_PATH_PREFIX: PathSegment[] = ["secrets", "providers"]; @@ -88,6 +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_APPLY_EXAMPLE_STDIN = formatCliCommand("openclaw config apply --stdin"); const CONFIG_SET_DESCRIPTION = [ "Set config values by path (value mode, ref/provider builder mode, or batch JSON mode).", "Examples:", @@ -96,6 +117,13 @@ 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.", + "Objects merge recursively, arrays/scalars replace, and null deletes a path.", + "Examples:", + CONFIG_APPLY_EXAMPLE_FILE, + CONFIG_APPLY_EXAMPLE_STDIN, +].join("\n"); const CONFIG_SET_POLICY_ERROR_MAX_ISSUES = 5; class ConfigSetDryRunValidationError extends Error { @@ -880,6 +908,147 @@ function parseBatchOperations(entries: ConfigSetBatchEntry[]): ConfigSetOperatio return operations; } +function configApplyModeError(message: string): Error { + return new Error(`config apply mode error: ${message}`); +} + +async function readStdinText(): Promise { + let raw = ""; + process.stdin.setEncoding("utf8"); + for await (const chunk of process.stdin) { + raw += String(chunk); + } + return raw; +} + +async function readConfigApplyPatch(opts: ConfigApplyOptions): Promise { + const file = normalizeOptionalString(opts.file); + const stdin = Boolean(opts.stdin); + if (Boolean(file) === stdin) { + throw configApplyModeError("provide exactly one of --file or --stdin."); + } + const sourceLabel = stdin ? "--stdin" : "--file"; + const raw = stdin ? await readStdinText() : fs.readFileSync(file as string, "utf8"); + try { + return JSON5.parse(raw); + } catch (err) { + throw new Error(`Failed to parse ${sourceLabel} as JSON5: ${String(err)}`, { cause: err }); + } +} + +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 buildDeleteOperation(path: PathSegment[]): ConfigSetOperation { + return { + inputMode: "json", + requestedPath: path, + setPath: path, + value: undefined, + mutation: "delete", + }; +} + +function buildApplyValueOperation(params: { + path: PathSegment[]; + value: unknown; + mutation?: "set" | "replace"; +}): ConfigSetOperation { + const ref = isPlainRecord(params.value) ? coerceSecretRef(params.value) : null; + if (ref) { + return { + ...buildRefAssignmentOperation({ + requestedPath: params.path, + ref: parseSecretRefFromUnknown(params.value, `patch.${toDotPath(params.path)}`), + inputMode: "json", + }), + ...(params.mutation ? { mutation: params.mutation } : {}), + }; + } + return { + ...buildValueAssignmentOperation({ + requestedPath: params.path, + value: params.value, + inputMode: "json", + }), + ...(params.mutation ? { mutation: params.mutation } : {}), + }; +} + +function buildConfigApplyOperations(params: { + patch: unknown; + replacePaths: PathSegment[][]; +}): ConfigSetOperation[] { + if (!isPlainRecord(params.patch)) { + throw configApplyModeError("input must be a JSON5 object patch."); + } + const operations: ConfigSetOperation[] = []; + const visit = (value: unknown, path: PathSegment[]) => { + validatePathSegments(path); + if (path.length > 0 && matchesAnyPath(path, params.replacePaths)) { + operations.push( + value === null + ? buildDeleteOperation(path) + : buildApplyValueOperation({ path, value, mutation: "replace" }), + ); + return; + } + if (path.length > 0 && value === null) { + operations.push(buildDeleteOperation(path)); + return; + } + if (path.length > 0 && isPlainRecord(value) && coerceSecretRef(value)) { + operations.push(buildApplyValueOperation({ path, value })); + return; + } + if (isPlainRecord(value)) { + for (const [key, child] of Object.entries(value)) { + visit(child, [...path, key]); + } + return; + } + if (path.length === 0) { + throw configApplyModeError("input must contain at least one config key."); + } + operations.push(buildApplyValueOperation({ path, value })); + }; + + visit(params.patch, []); + if (operations.length === 0) { + throw configApplyModeError("input patch did not contain any config updates."); + } + return operations; +} + +function collectSecretRefsFromUnknown(value: unknown): SecretRef[] { + const refs: SecretRef[] = []; + const visit = (candidate: unknown) => { + const ref = coerceSecretRef(candidate); + if (ref) { + refs.push(ref); + return; + } + if (Array.isArray(candidate)) { + for (const entry of candidate) { + visit(entry); + } + return; + } + if (isPlainRecord(candidate)) { + for (const entry of Object.values(candidate)) { + visit(entry); + } + } + }; + visit(value); + return refs; +} + function modeError(message: string): Error { return new Error(`config set mode error: ${message}`); } @@ -978,6 +1147,9 @@ function collectDryRunRefs(params: { if (operation.assignedRef) { refsByKey.set(secretRefKey(operation.assignedRef), operation.assignedRef); } + for (const ref of collectSecretRefsFromUnknown(operation.value)) { + refsByKey.set(secretRefKey(ref), ref); + } if (operation.touchedSecretTargetPath) { targetPaths.add(operation.touchedSecretTargetPath); } @@ -1170,6 +1342,203 @@ function formatDryRunFailureMessage(params: { return lines.join("\n"); } +async function runConfigOperations(params: { + runtime: RuntimeEnv; + operations: ConfigSetOperation[]; + options: ConfigMutationOptions; + successMode: "set" | "apply"; +}) { + const { runtime, operations, options } = params; + if ( + operations.some((operation) => + pathStartsWith(operation.requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX), + ) + ) { + throw new Error(formatPluginInstallConfigSetError()); + } + 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; + const unsetPaths: PathSegment[][] = []; + for (const operation of operations) { + if (operation.mutation === "delete") { + unsetAtPath(next, operation.setPath); + unsetPaths.push(operation.setPath); + continue; + } + if (operation.mutation === "merge" || (options.merge && operation.mutation !== "replace")) { + mergeAtPath(next, operation.setPath, operation.value); + } else { + assertNonDestructiveReplacement({ + root: next, + path: operation.setPath, + value: operation.value, + allowReplace: options.replace || operation.mutation === "replace", + }); + setAtPath(next, operation.setPath, operation.value); + } + } + const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({ + root: next, + operations, + }); + const nextConfig = next as OpenClawConfig; + const policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig); + const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map( + (line) => line.trim(), + ); + + if (options.dryRun) { + const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); + const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); + const requiresFullSchemaValidation = operations.some( + (operation) => operation.inputMode === "json" && operation.schemaValidated !== true, + ); + const refs = + hasJsonMode || hasBuilderMode + ? collectDryRunRefs({ + config: nextConfig, + operations, + }) + : []; + const selectedDryRunRefs = selectDryRunRefsForResolution({ + refs, + allowExecInDryRun: Boolean(options.allowExec), + }); + const errors: ConfigSetDryRunError[] = []; + if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) { + errors.push( + ...policyIssueLines.map((message) => ({ + kind: "schema" as const, + message, + })), + ); + } + if (requiresFullSchemaValidation) { + errors.push( + ...collectDryRunSchemaErrors({ + config: nextConfig, + operations, + }), + ); + } + if (hasJsonMode || hasBuilderMode) { + errors.push( + ...collectDryRunStaticErrorsForSkippedExecRefs({ + refs: selectedDryRunRefs.skippedExecRefs, + config: nextConfig, + }), + ); + errors.push( + ...(await collectDryRunResolvabilityErrors({ + refs: selectedDryRunRefs.refsToResolve, + config: nextConfig, + })), + ); + } + const dedupedErrors = dedupeDryRunErrors(errors); + const dryRunResult: ConfigSetDryRunResult = { + ok: dedupedErrors.length === 0, + operations: operations.length, + configPath: shortenHomePath(snapshot.path), + inputModes: [...new Set(operations.map((operation) => operation.inputMode))], + checks: { + schema: requiresFullSchemaValidation || policyIssueLines.length > 0, + resolvability: hasJsonMode || hasBuilderMode, + resolvabilityComplete: + (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, + }, + refsChecked: selectedDryRunRefs.refsToResolve.length, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + ...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}), + }; + if (dedupedErrors.length > 0) { + if (options.json) { + throw new ConfigSetDryRunValidationError(dryRunResult); + } + throw new Error( + formatDryRunFailureMessage({ + errors: dedupedErrors, + skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, + }), + ); + } + if (options.json) { + writeRuntimeJson(runtime, dryRunResult); + } else { + if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) { + runtime.log( + info( + "Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.", + ), + ); + } + if (dryRunResult.skippedExecRefs > 0) { + runtime.log( + info( + `Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, + ), + ); + } + runtime.log( + info( + `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, + ), + ); + } + return; + } + if (policyIssueLines.length > 0) { + throw new Error(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines)); + } + + await replaceConfigFile({ + nextConfig: next, + ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), + ...(unsetPaths.length > 0 ? { writeOptions: { unsetPaths } } : {}), + }); + if (removedGatewayAuthPaths.length > 0) { + runtime.log( + info( + `Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? ""}.`, + ), + ); + } + if (params.successMode === "set" && operations.length === 1) { + const operation = operations[0]; + const action = operation?.mutation === "delete" ? "Removed" : "Updated"; + runtime.log( + info(`${action} ${toDotPath(operation?.requestedPath ?? [])}. Restart the gateway to apply.`), + ); + return; + } + if (params.successMode === "set") { + runtime.log(info(`Updated ${operations.length} config paths. Restart the gateway to apply.`)); + return; + } + runtime.log(info(`Applied ${operations.length} config update(s). Restart the gateway to apply.`)); +} + +function handleConfigMutationError(params: { + err: unknown; + runtime: RuntimeEnv; + options: ConfigMutationOptions; +}) { + if ( + params.options.dryRun && + params.options.json && + params.err instanceof ConfigSetDryRunValidationError + ) { + writeRuntimeJson(params.runtime, params.err.result); + params.runtime.exit(1); + return; + } + params.runtime.error(danger(String(params.err))); + params.runtime.exit(1); +} + export async function runConfigSet(opts: { path?: string; value?: string; @@ -1208,177 +1577,46 @@ export async function runConfigSet(opts: { value: opts.value, opts: opts.cliOptions, }); - if ( - operations.some((operation) => - pathStartsWith(operation.requestedPath, PLUGIN_INSTALL_RECORD_PATH_PREFIX), - ) - ) { - throw new Error(formatPluginInstallConfigSetError()); - } - 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; - for (const operation of operations) { - if (opts.cliOptions.merge) { - mergeAtPath(next, operation.setPath, operation.value); - } else { - assertNonDestructiveReplacement({ - root: next, - path: operation.setPath, - value: operation.value, - allowReplace: opts.cliOptions.replace, - }); - setAtPath(next, operation.setPath, operation.value); - } - } - const removedGatewayAuthPaths = pruneInactiveGatewayAuthCredentials({ - root: next, + await runConfigOperations({ + runtime, operations, + options: opts.cliOptions, + successMode: "set", }); - const nextConfig = next as OpenClawConfig; - const policyIssues = collectUnsupportedSecretRefPolicyIssues(nextConfig); - const policyIssueLines = formatConfigIssueLines(policyIssues, "", { normalizeRoot: true }).map( - (line) => line.trim(), - ); - - if (opts.cliOptions.dryRun) { - const hasJsonMode = operations.some((operation) => operation.inputMode === "json"); - const hasBuilderMode = operations.some((operation) => operation.inputMode === "builder"); - const requiresFullSchemaValidation = operations.some( - (operation) => operation.inputMode === "json" && operation.schemaValidated !== true, - ); - const refs = - hasJsonMode || hasBuilderMode - ? collectDryRunRefs({ - config: nextConfig, - operations, - }) - : []; - const selectedDryRunRefs = selectDryRunRefsForResolution({ - refs, - allowExecInDryRun: Boolean(opts.cliOptions.allowExec), - }); - const errors: ConfigSetDryRunError[] = []; - if ((!hasJsonMode || !requiresFullSchemaValidation) && policyIssueLines.length > 0) { - errors.push( - ...policyIssueLines.map((message) => ({ - kind: "schema" as const, - message, - })), - ); - } - if (requiresFullSchemaValidation) { - errors.push( - ...collectDryRunSchemaErrors({ - config: nextConfig, - operations, - }), - ); - } - if (hasJsonMode || hasBuilderMode) { - errors.push( - ...collectDryRunStaticErrorsForSkippedExecRefs({ - refs: selectedDryRunRefs.skippedExecRefs, - config: nextConfig, - }), - ); - errors.push( - ...(await collectDryRunResolvabilityErrors({ - refs: selectedDryRunRefs.refsToResolve, - config: nextConfig, - })), - ); - } - const dedupedErrors = dedupeDryRunErrors(errors); - const dryRunResult: ConfigSetDryRunResult = { - ok: dedupedErrors.length === 0, - operations: operations.length, - configPath: shortenHomePath(snapshot.path), - inputModes: [...new Set(operations.map((operation) => operation.inputMode))], - checks: { - schema: requiresFullSchemaValidation || policyIssueLines.length > 0, - resolvability: hasJsonMode || hasBuilderMode, - resolvabilityComplete: - (hasJsonMode || hasBuilderMode) && selectedDryRunRefs.skippedExecRefs.length === 0, - }, - refsChecked: selectedDryRunRefs.refsToResolve.length, - skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, - ...(dedupedErrors.length > 0 ? { errors: dedupedErrors } : {}), - }; - if (dedupedErrors.length > 0) { - if (opts.cliOptions.json) { - throw new ConfigSetDryRunValidationError(dryRunResult); - } - throw new Error( - formatDryRunFailureMessage({ - errors: dedupedErrors, - skippedExecRefs: selectedDryRunRefs.skippedExecRefs.length, - }), - ); - } - if (opts.cliOptions.json) { - writeRuntimeJson(runtime, dryRunResult); - } else { - if (!dryRunResult.checks.schema && !dryRunResult.checks.resolvability) { - runtime.log( - info( - "Dry run note: value mode does not run schema/resolvability checks. Use --strict-json, builder flags, or batch mode to enable validation checks.", - ), - ); - } - if (dryRunResult.skippedExecRefs > 0) { - runtime.log( - info( - `Dry run note: skipped ${dryRunResult.skippedExecRefs} exec SecretRef resolvability check(s). Re-run with --allow-exec to execute exec providers during dry-run.`, - ), - ); - } - runtime.log( - info( - `Dry run successful: ${operations.length} update(s) validated against ${shortenHomePath(snapshot.path)}.`, - ), - ); - } - return; - } - if (policyIssueLines.length > 0) { - throw new Error(formatUnsupportedSecretRefPolicyFailureMessage(policyIssueLines)); - } - - await replaceConfigFile({ - nextConfig: next, - ...(snapshot.hash !== undefined ? { baseHash: snapshot.hash } : {}), - }); - if (removedGatewayAuthPaths.length > 0) { - runtime.log( - info( - `Removed inactive ${removedGatewayAuthPaths.join(", ")} for gateway.auth.mode=${nextConfig.gateway?.auth?.mode ?? ""}.`, - ), - ); - } - 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 - ) { - writeRuntimeJson(runtime, err.result); - runtime.exit(1); - return; + handleConfigMutationError({ err, runtime, options: opts.cliOptions }); + } +} + +export async function runConfigApply(opts: { + cliOptions: ConfigApplyOptions; + runtime?: RuntimeEnv; +}) { + const runtime = opts.runtime ?? defaultRuntime; + try { + if (opts.cliOptions.allowExec && !opts.cliOptions.dryRun) { + throw configApplyModeError("--allow-exec requires --dry-run."); } - runtime.error(danger(String(err))); - runtime.exit(1); + if (opts.cliOptions.json && !opts.cliOptions.dryRun) { + throw configApplyModeError("--json requires --dry-run."); + } + const patch = await readConfigApplyPatch(opts.cliOptions); + const operations = buildConfigApplyOperations({ + patch, + replacePaths: parseReplacePaths(opts.cliOptions.replacePath), + }); + await runConfigOperations({ + runtime, + operations, + options: { + dryRun: opts.cliOptions.dryRun, + allowExec: opts.cliOptions.allowExec, + json: opts.cliOptions.json, + }, + successMode: "apply", + }); + } catch (err) { + handleConfigMutationError({ err, runtime, options: opts.cliOptions }); } } @@ -1530,7 +1768,7 @@ export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( - "Non-interactive config helpers (get/set/unset/file/schema/validate). Run without subcommand for guided setup.", + "Non-interactive config helpers (get/set/apply/unset/file/schema/validate). Run without subcommand for guided setup.", ) .addHelpText( "after", @@ -1641,6 +1879,32 @@ export function registerConfigCli(program: Command) { }); }); + cmd + .command("apply") + .description(CONFIG_APPLY_DESCRIPTION) + .option("--file ", "Read a JSON5 config patch object from file") + .option("--stdin", "Read a JSON5 config patch object from stdin", false) + .option( + "--dry-run", + "Validate changes without writing openclaw.json (checks schema and SecretRef resolvability; exec SecretRefs are skipped unless --allow-exec is set)", + false, + ) + .option( + "--allow-exec", + "Dry-run only: allow exec SecretRef resolvability checks (may execute provider commands)", + false, + ) + .option("--json", "Output dry-run result as JSON", false) + .option( + "--replace-path ", + "Replace the object or array at this dot/bracket path instead of recursively applying it (repeatable)", + (value: string, previous: string[]) => [...previous, value], + [] as string[], + ) + .action(async (opts: ConfigApplyOptions) => { + await runConfigApply({ cliOptions: opts }); + }); + cmd .command("unset") .description("Remove a config value by dot path")