Skip to content

Commit 3002f13

Browse files
SidSid-Qingumadeiras
authored
feat(config): add openclaw config validate and improve startup error messages (#31220)
Merged via squash. Prepared head SHA: 4598f2a Co-authored-by: Sid-Qin <[email protected]> Co-authored-by: gumadeiras <[email protected]> Reviewed-by: @gumadeiras
1 parent 5a2200b commit 3002f13

8 files changed

Lines changed: 232 additions & 12 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
66

77
### Changes
88

9+
- CLI/Config validation: add `openclaw config validate` (with `--json`) to validate config files before gateway startup, and include detailed invalid-key paths in startup invalid-config errors. (#31220) thanks @Sid-Qin.
910
- Sessions/Attachments: add inline file attachment support for `sessions_spawn` (subagent runtime only) with base64/utf8 encoding, transcript content redaction, lifecycle cleanup, and configurable limits via `tools.sessions_spawn.attachments`. (#16761) Thanks @napetrov.
1011
- Agents/Thinking defaults: set `adaptive` as the default thinking level for Anthropic Claude 4.6 models (including Bedrock Claude 4.6 refs) while keeping other reasoning-capable models at `low` unless explicitly configured.
1112
- Gateway/Container probes: add built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes health checks, with fallback routing so existing handlers on those paths are not shadowed. (#31272) Thanks @vincentkoc.

docs/cli/config.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
---
2-
summary: "CLI reference for `openclaw config` (get/set/unset values and config file path)"
2+
summary: "CLI reference for `openclaw config` (get/set/unset/file/validate)"
33
read_when:
44
- You want to read or edit config non-interactively
55
title: "config"
66
---
77

88
# `openclaw config`
99

10-
Config helpers: get/set/unset values by path and print the active config file.
11-
Run without a subcommand to open
10+
Config helpers: get/set/unset/validate values by path and print the active
11+
config file. Run without a subcommand to open
1212
the configure wizard (same as `openclaw configure`).
1313

1414
## Examples
@@ -20,6 +20,8 @@ openclaw config set browser.executablePath "/usr/bin/google-chrome"
2020
openclaw config set agents.defaults.heartbeat.every "2h"
2121
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
2222
openclaw config unset tools.web.search.apiKey
23+
openclaw config validate
24+
openclaw config validate --json
2325
```
2426

2527
## Paths
@@ -54,3 +56,13 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json
5456
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
5557

5658
Restart the gateway after edits.
59+
60+
## Validate
61+
62+
Validate the current config against the active schema without starting the
63+
gateway.
64+
65+
```bash
66+
openclaw config validate
67+
openclaw config validate --json
68+
```

docs/cli/index.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -380,7 +380,7 @@ Interactive configuration wizard (models, channels, skills, gateway).
380380

381381
### `config`
382382

383-
Non-interactive config helpers (get/set/unset/file). Running `openclaw config` with no
383+
Non-interactive config helpers (get/set/unset/file/validate). Running `openclaw config` with no
384384
subcommand launches the wizard.
385385

386386
Subcommands:
@@ -389,6 +389,8 @@ Subcommands:
389389
- `config set <path> <value>`: set a value (JSON5 or raw string).
390390
- `config unset <path>`: remove a value.
391391
- `config file`: print the active config file path.
392+
- `config validate`: validate the current config against the schema without starting the gateway.
393+
- `config validate --json`: emit machine-readable JSON output.
392394

393395
### `doctor`
394396

src/cli/config-cli.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) {
5656
mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
5757
}
5858

59+
function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
60+
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
61+
}
62+
5963
let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
6064

6165
async function runConfigCommand(args: string[]) {
@@ -178,6 +182,99 @@ describe("config cli", () => {
178182
});
179183
});
180184

185+
describe("config validate", () => {
186+
it("prints success and exits 0 when config is valid", async () => {
187+
const resolved: OpenClawConfig = {
188+
gateway: { port: 18789 },
189+
};
190+
setSnapshot(resolved, resolved);
191+
192+
await runConfigCommand(["config", "validate"]);
193+
194+
expect(mockExit).not.toHaveBeenCalled();
195+
expect(mockError).not.toHaveBeenCalled();
196+
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Config valid:"));
197+
});
198+
199+
it("prints issues and exits 1 when config is invalid", async () => {
200+
setSnapshotOnce({
201+
path: "/tmp/custom-openclaw.json",
202+
exists: true,
203+
raw: "{}",
204+
parsed: {},
205+
resolved: {},
206+
valid: false,
207+
config: {},
208+
issues: [
209+
{
210+
path: "agents.defaults.suppressToolErrorWarnings",
211+
message: "Unrecognized key(s) in object",
212+
},
213+
],
214+
warnings: [],
215+
legacyIssues: [],
216+
});
217+
218+
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
219+
220+
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config invalid at"));
221+
expect(mockError).toHaveBeenCalledWith(
222+
expect.stringContaining("agents.defaults.suppressToolErrorWarnings"),
223+
);
224+
expect(mockLog).not.toHaveBeenCalled();
225+
});
226+
227+
it("returns machine-readable JSON with --json for invalid config", async () => {
228+
setSnapshotOnce({
229+
path: "/tmp/custom-openclaw.json",
230+
exists: true,
231+
raw: "{}",
232+
parsed: {},
233+
resolved: {},
234+
valid: false,
235+
config: {},
236+
issues: [{ path: "gateway.bind", message: "Invalid enum value" }],
237+
warnings: [],
238+
legacyIssues: [],
239+
});
240+
241+
await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow(
242+
"__exit__:1",
243+
);
244+
245+
const raw = mockLog.mock.calls.at(0)?.[0];
246+
expect(typeof raw).toBe("string");
247+
const payload = JSON.parse(String(raw)) as {
248+
valid: boolean;
249+
path: string;
250+
issues: Array<{ path: string; message: string }>;
251+
};
252+
expect(payload.valid).toBe(false);
253+
expect(payload.path).toBe("/tmp/custom-openclaw.json");
254+
expect(payload.issues).toEqual([{ path: "gateway.bind", message: "Invalid enum value" }]);
255+
expect(mockError).not.toHaveBeenCalled();
256+
});
257+
258+
it("prints file-not-found and exits 1 when config file is missing", async () => {
259+
setSnapshotOnce({
260+
path: "/tmp/openclaw.json",
261+
exists: false,
262+
raw: null,
263+
parsed: {},
264+
resolved: {},
265+
valid: true,
266+
config: {},
267+
issues: [],
268+
warnings: [],
269+
legacyIssues: [],
270+
});
271+
272+
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
273+
expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config file not found:"));
274+
expect(mockLog).not.toHaveBeenCalled();
275+
});
276+
});
277+
181278
describe("config set parsing flags", () => {
182279
it("falls back to raw string when parsing fails and strict mode is off", async () => {
183280
const resolved: OpenClawConfig = { gateway: { port: 18789 } };

src/cli/config-cli.ts

Lines changed: 84 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import type { Command } from "commander";
22
import JSON5 from "json5";
33
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
4+
import { CONFIG_PATH } from "../config/paths.js";
45
import { isBlockedObjectKey } from "../config/prototype-keys.js";
56
import { redactConfigObject } from "../config/redact-snapshot.js";
6-
import { danger, info } from "../globals.js";
7+
import { danger, info, success } from "../globals.js";
78
import type { RuntimeEnv } from "../runtime.js";
89
import { defaultRuntime } from "../runtime.js";
910
import { formatDocsLink } from "../terminal/links.js";
@@ -15,6 +16,10 @@ type PathSegment = string;
1516
type ConfigSetParseOpts = {
1617
strictJson?: boolean;
1718
};
19+
type ConfigIssue = {
20+
path: string;
21+
message: string;
22+
};
1823

1924
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
2025
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
@@ -97,6 +102,21 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
97102
return Object.prototype.hasOwnProperty.call(value, key);
98103
}
99104

105+
function normalizeConfigIssues(issues: ReadonlyArray<ConfigIssue>): ConfigIssue[] {
106+
return issues.map((issue) => ({
107+
path: issue.path || "<root>",
108+
message: issue.message,
109+
}));
110+
}
111+
112+
function formatConfigIssueLines(issues: ReadonlyArray<ConfigIssue>, marker: string): string[] {
113+
return normalizeConfigIssues(issues).map((issue) => `${marker} ${issue.path}: ${issue.message}`);
114+
}
115+
116+
function formatDoctorHint(message: string): string {
117+
return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`;
118+
}
119+
100120
function validatePathSegments(path: PathSegment[]): void {
101121
for (const segment of path) {
102122
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
@@ -229,10 +249,10 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
229249
return snapshot;
230250
}
231251
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
232-
for (const issue of snapshot.issues) {
233-
runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
252+
for (const line of formatConfigIssueLines(snapshot.issues, "-")) {
253+
runtime.error(line);
234254
}
235-
runtime.error(`Run \`${formatCliCommand("openclaw doctor")}\` to repair, then retry.`);
255+
runtime.error(formatDoctorHint("to repair, then retry."));
236256
runtime.exit(1);
237257
return snapshot;
238258
}
@@ -335,11 +355,62 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) {
335355
}
336356
}
337357

358+
export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) {
359+
const runtime = opts.runtime ?? defaultRuntime;
360+
let outputPath = CONFIG_PATH ?? "openclaw.json";
361+
362+
try {
363+
const snapshot = await readConfigFileSnapshot();
364+
outputPath = snapshot.path;
365+
const shortPath = shortenHomePath(outputPath);
366+
367+
if (!snapshot.exists) {
368+
if (opts.json) {
369+
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" }));
370+
} else {
371+
runtime.error(danger(`Config file not found: ${shortPath}`));
372+
}
373+
runtime.exit(1);
374+
return;
375+
}
376+
377+
if (!snapshot.valid) {
378+
const issues = normalizeConfigIssues(snapshot.issues);
379+
380+
if (opts.json) {
381+
runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2));
382+
} else {
383+
runtime.error(danger(`Config invalid at ${shortPath}:`));
384+
for (const line of formatConfigIssueLines(issues, danger("×"))) {
385+
runtime.error(` ${line}`);
386+
}
387+
runtime.error("");
388+
runtime.error(formatDoctorHint("to repair, or fix the keys above manually."));
389+
}
390+
runtime.exit(1);
391+
return;
392+
}
393+
394+
if (opts.json) {
395+
runtime.log(JSON.stringify({ valid: true, path: outputPath }));
396+
} else {
397+
runtime.log(success(`Config valid: ${shortPath}`));
398+
}
399+
} catch (err) {
400+
if (opts.json) {
401+
runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) }));
402+
} else {
403+
runtime.error(danger(`Config validation error: ${String(err)}`));
404+
}
405+
runtime.exit(1);
406+
}
407+
}
408+
338409
export function registerConfigCli(program: Command) {
339410
const cmd = program
340411
.command("config")
341412
.description(
342-
"Non-interactive config helpers (get/set/unset/file). Run without subcommand for the setup wizard.",
413+
"Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for the setup wizard.",
343414
)
344415
.addHelpText(
345416
"after",
@@ -408,4 +479,12 @@ export function registerConfigCli(program: Command) {
408479
.action(async () => {
409480
await runConfigFile({});
410481
});
482+
483+
cmd
484+
.command("validate")
485+
.description("Validate the current config against the schema without starting the gateway")
486+
.option("--json", "Output validation result as JSON", false)
487+
.action(async (opts) => {
488+
await runConfigValidate({ json: Boolean(opts.json) });
489+
});
411490
}

src/cli/program/command-registry.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ const coreEntries: CoreCliEntry[] = [
8383
{
8484
name: "config",
8585
description:
86-
"Non-interactive config helpers (get/set/unset/file). Default: starts setup wizard.",
86+
"Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.",
8787
hasSubcommands: true,
8888
},
8989
],

src/config/io.compat.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import fs from "node:fs/promises";
22
import os from "node:os";
33
import path from "node:path";
4-
import { describe, expect, it } from "vitest";
4+
import { describe, expect, it, vi } from "vitest";
55
import { createConfigIO } from "./io.js";
66

77
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
@@ -137,4 +137,33 @@ describe("config io paths", () => {
137137
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
138138
});
139139
});
140+
141+
it("logs invalid config path details and returns empty config", async () => {
142+
await withTempHome(async (home) => {
143+
const configDir = path.join(home, ".openclaw");
144+
await fs.mkdir(configDir, { recursive: true });
145+
const configPath = path.join(configDir, "openclaw.json");
146+
await fs.writeFile(
147+
configPath,
148+
JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2),
149+
);
150+
151+
const logger = {
152+
warn: vi.fn(),
153+
error: vi.fn(),
154+
};
155+
156+
const io = createConfigIO({
157+
env: {} as NodeJS.ProcessEnv,
158+
homedir: () => home,
159+
logger,
160+
});
161+
162+
expect(io.loadConfig()).toEqual({});
163+
expect(logger.error).toHaveBeenCalledWith(
164+
expect.stringContaining(`Invalid config at ${configPath}:\\n`),
165+
);
166+
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:"));
167+
});
168+
});
140169
});

src/config/io.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -720,7 +720,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
720720
loggedInvalidConfigs.add(configPath);
721721
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
722722
}
723-
const error = new Error("Invalid config");
723+
const error = new Error(`Invalid config at ${configPath}:\n${details}`);
724724
(error as { code?: string; details?: string }).code = "INVALID_CONFIG";
725725
(error as { code?: string; details?: string }).details = details;
726726
throw error;

0 commit comments

Comments
 (0)