feat: add config apply patch command

This commit is contained in:
Peter Steinberger
2026-04-29 15:59:26 +01:00
parent 1e4a37fbfb
commit 48a01798b0
6 changed files with 848 additions and 180 deletions

View File

@@ -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).
</Tab>
</Tabs>

View File

@@ -36,17 +36,25 @@ Production-ready for DMs and channels via Slack app integrations. Default mode i
<Step title="Configure OpenClaw">
```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
<Step title="Configure OpenClaw">
```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
```
<Note>

View File

@@ -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 <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

View File

@@ -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 <requestId>`. 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 <vm-name>.exe.xyz 'openclaw config apply --stdin --dry-run' < ./openclaw.remote.patch.json5
ssh <vm-name>.exe.xyz 'openclaw config apply --stdin' < ./openclaw.remote.patch.json5
ssh <vm-name>.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 <vm-name>.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

View File

@@ -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<string, unknown>;
expect(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>).models,
).toEqual({
"openai/gpt-5.4": { alias: "GPT 5.4" },
"openai/gpt-5.5": { params: { fastMode: true } },
});
expect(
(
((written.agents as Record<string, unknown>).defaults as Record<string, unknown>)
.model as Record<string, unknown>
).primary,
).toBe("openai/gpt-5.5");
expect(
((written.channels as Record<string, unknown>).slack as Record<string, unknown>).botToken,
).toEqual({ source: "env", provider: "default", id: "SLACK_BOT_TOKEN" });
expect(
((written.channels as Record<string, unknown>).discord as Record<string, unknown>).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<string, unknown>;
const channels = (written.channels as Record<string, unknown>).discord as Record<
string,
unknown
>;
expect(
((channels.guilds as Record<string, unknown>).guild as Record<string, unknown>)
.channels as Record<string, unknown>,
).toEqual({ maintainers: { enabled: true, requireMention: true } });
expect((written.channels as Record<string, unknown>).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([

View File

@@ -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<string> {
let raw = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) {
raw += String(chunk);
}
return raw;
}
async function readConfigApplyPatch(opts: ConfigApplyOptions): Promise<unknown> {
const file = normalizeOptionalString(opts.file);
const stdin = Boolean(opts.stdin);
if (Boolean(file) === stdin) {
throw configApplyModeError("provide exactly one of --file <path> 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<string, unknown>;
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 ?? "<unset>"}.`,
),
);
}
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<string, unknown>;
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 ?? "<unset>"}.`,
),
);
}
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 <path>", "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 <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")