diff --git a/CHANGELOG.md b/CHANGELOG.md index a0df2f30e74..6b8fbf08995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- 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. - 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. - 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. - 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. diff --git a/docs/cli/config.md b/docs/cli/config.md index 8bee6deec7a..fa0d62e8511 100644 --- a/docs/cli/config.md +++ b/docs/cli/config.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw config` (get/set/unset values and config file path)" +summary: "CLI reference for `openclaw config` (get/set/unset/file/validate)" read_when: - You want to read or edit config non-interactively title: "config" @@ -7,8 +7,8 @@ title: "config" # `openclaw config` -Config helpers: get/set/unset values by path and print the active config file. -Run without a subcommand to open +Config helpers: get/set/unset/validate values by path and print the active +config file. Run without a subcommand to open the configure wizard (same as `openclaw configure`). ## Examples @@ -20,6 +20,8 @@ openclaw config set browser.executablePath "/usr/bin/google-chrome" openclaw config set agents.defaults.heartbeat.every "2h" openclaw config set agents.list[0].tools.exec.node "node-id-or-name" openclaw config unset tools.web.search.apiKey +openclaw config validate +openclaw config validate --json ``` ## Paths @@ -54,3 +56,13 @@ openclaw config set channels.whatsapp.groups '["*"]' --strict-json - `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location). Restart the gateway after edits. + +## Validate + +Validate the current config against the active schema without starting the +gateway. + +```bash +openclaw config validate +openclaw config validate --json +``` diff --git a/docs/cli/index.md b/docs/cli/index.md index a20c53aad19..210362d0391 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -380,7 +380,7 @@ Interactive configuration wizard (models, channels, skills, gateway). ### `config` -Non-interactive config helpers (get/set/unset/file). Running `openclaw config` with no +Non-interactive config helpers (get/set/unset/file/validate). Running `openclaw config` with no subcommand launches the wizard. Subcommands: @@ -389,6 +389,8 @@ Subcommands: - `config set `: set a value (JSON5 or raw string). - `config unset `: remove a value. - `config file`: print the active config file path. +- `config validate`: validate the current config against the schema without starting the gateway. +- `config validate --json`: emit machine-readable JSON output. ### `doctor` diff --git a/src/cli/config-cli.test.ts b/src/cli/config-cli.test.ts index f0dc2fd6fc5..b693e8b64ac 100644 --- a/src/cli/config-cli.test.ts +++ b/src/cli/config-cli.test.ts @@ -56,6 +56,10 @@ function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) { mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config })); } +function setSnapshotOnce(snapshot: ConfigFileSnapshot) { + mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot); +} + let registerConfigCli: typeof import("./config-cli.js").registerConfigCli; async function runConfigCommand(args: string[]) { @@ -178,6 +182,99 @@ describe("config cli", () => { }); }); + describe("config validate", () => { + it("prints success and exits 0 when config is valid", async () => { + const resolved: OpenClawConfig = { + gateway: { port: 18789 }, + }; + setSnapshot(resolved, resolved); + + await runConfigCommand(["config", "validate"]); + + expect(mockExit).not.toHaveBeenCalled(); + expect(mockError).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Config valid:")); + }); + + it("prints issues and exits 1 when config is invalid", async () => { + setSnapshotOnce({ + path: "/tmp/custom-openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: false, + config: {}, + issues: [ + { + path: "agents.defaults.suppressToolErrorWarnings", + message: "Unrecognized key(s) in object", + }, + ], + warnings: [], + legacyIssues: [], + }); + + await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1"); + + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config invalid at")); + expect(mockError).toHaveBeenCalledWith( + expect.stringContaining("agents.defaults.suppressToolErrorWarnings"), + ); + expect(mockLog).not.toHaveBeenCalled(); + }); + + it("returns machine-readable JSON with --json for invalid config", async () => { + setSnapshotOnce({ + path: "/tmp/custom-openclaw.json", + exists: true, + raw: "{}", + parsed: {}, + resolved: {}, + valid: false, + config: {}, + issues: [{ path: "gateway.bind", message: "Invalid enum value" }], + warnings: [], + legacyIssues: [], + }); + + await expect(runConfigCommand(["config", "validate", "--json"])).rejects.toThrow( + "__exit__:1", + ); + + const raw = mockLog.mock.calls.at(0)?.[0]; + expect(typeof raw).toBe("string"); + const payload = JSON.parse(String(raw)) as { + valid: boolean; + path: string; + issues: Array<{ path: string; message: string }>; + }; + expect(payload.valid).toBe(false); + expect(payload.path).toBe("/tmp/custom-openclaw.json"); + expect(payload.issues).toEqual([{ path: "gateway.bind", message: "Invalid enum value" }]); + expect(mockError).not.toHaveBeenCalled(); + }); + + it("prints file-not-found and exits 1 when config file is missing", async () => { + setSnapshotOnce({ + path: "/tmp/openclaw.json", + exists: false, + raw: null, + parsed: {}, + resolved: {}, + valid: true, + config: {}, + issues: [], + warnings: [], + legacyIssues: [], + }); + + await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1"); + expect(mockError).toHaveBeenCalledWith(expect.stringContaining("Config file not found:")); + expect(mockLog).not.toHaveBeenCalled(); + }); + }); + describe("config set parsing flags", () => { it("falls back to raw string when parsing fails and strict mode is off", async () => { const resolved: OpenClawConfig = { gateway: { port: 18789 } }; diff --git a/src/cli/config-cli.ts b/src/cli/config-cli.ts index 13cc72b1111..d73d340b7c3 100644 --- a/src/cli/config-cli.ts +++ b/src/cli/config-cli.ts @@ -1,9 +1,10 @@ import type { Command } from "commander"; import JSON5 from "json5"; import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; +import { CONFIG_PATH } from "../config/paths.js"; import { isBlockedObjectKey } from "../config/prototype-keys.js"; import { redactConfigObject } from "../config/redact-snapshot.js"; -import { danger, info } from "../globals.js"; +import { danger, info, success } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; @@ -15,6 +16,10 @@ type PathSegment = string; type ConfigSetParseOpts = { strictJson?: boolean; }; +type ConfigIssue = { + path: string; + message: string; +}; const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"]; const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"]; @@ -97,6 +102,21 @@ function hasOwnPathKey(value: Record, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); } +function normalizeConfigIssues(issues: ReadonlyArray): ConfigIssue[] { + return issues.map((issue) => ({ + path: issue.path || "", + message: issue.message, + })); +} + +function formatConfigIssueLines(issues: ReadonlyArray, marker: string): string[] { + return normalizeConfigIssues(issues).map((issue) => `${marker} ${issue.path}: ${issue.message}`); +} + +function formatDoctorHint(message: string): string { + return `Run \`${formatCliCommand("openclaw doctor")}\` ${message}`; +} + function validatePathSegments(path: PathSegment[]): void { for (const segment of path) { if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) { @@ -229,10 +249,10 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) { return snapshot; } runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`); - for (const issue of snapshot.issues) { - runtime.error(`- ${issue.path || ""}: ${issue.message}`); + for (const line of formatConfigIssueLines(snapshot.issues, "-")) { + runtime.error(line); } - runtime.error(`Run \`${formatCliCommand("openclaw doctor")}\` to repair, then retry.`); + runtime.error(formatDoctorHint("to repair, then retry.")); runtime.exit(1); return snapshot; } @@ -335,11 +355,62 @@ export async function runConfigFile(opts: { runtime?: RuntimeEnv }) { } } +export async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv } = {}) { + const runtime = opts.runtime ?? defaultRuntime; + let outputPath = CONFIG_PATH ?? "openclaw.json"; + + try { + const snapshot = await readConfigFileSnapshot(); + outputPath = snapshot.path; + const shortPath = shortenHomePath(outputPath); + + if (!snapshot.exists) { + if (opts.json) { + runtime.log(JSON.stringify({ valid: false, path: outputPath, error: "file not found" })); + } else { + runtime.error(danger(`Config file not found: ${shortPath}`)); + } + runtime.exit(1); + return; + } + + if (!snapshot.valid) { + const issues = normalizeConfigIssues(snapshot.issues); + + if (opts.json) { + runtime.log(JSON.stringify({ valid: false, path: outputPath, issues }, null, 2)); + } else { + runtime.error(danger(`Config invalid at ${shortPath}:`)); + for (const line of formatConfigIssueLines(issues, danger("×"))) { + runtime.error(` ${line}`); + } + runtime.error(""); + runtime.error(formatDoctorHint("to repair, or fix the keys above manually.")); + } + runtime.exit(1); + return; + } + + if (opts.json) { + runtime.log(JSON.stringify({ valid: true, path: outputPath })); + } else { + runtime.log(success(`Config valid: ${shortPath}`)); + } + } catch (err) { + if (opts.json) { + runtime.log(JSON.stringify({ valid: false, path: outputPath, error: String(err) })); + } else { + runtime.error(danger(`Config validation error: ${String(err)}`)); + } + runtime.exit(1); + } +} + export function registerConfigCli(program: Command) { const cmd = program .command("config") .description( - "Non-interactive config helpers (get/set/unset/file). Run without subcommand for the setup wizard.", + "Non-interactive config helpers (get/set/unset/file/validate). Run without subcommand for the setup wizard.", ) .addHelpText( "after", @@ -408,4 +479,12 @@ export function registerConfigCli(program: Command) { .action(async () => { await runConfigFile({}); }); + + cmd + .command("validate") + .description("Validate the current config against the schema without starting the gateway") + .option("--json", "Output validation result as JSON", false) + .action(async (opts) => { + await runConfigValidate({ json: Boolean(opts.json) }); + }); } diff --git a/src/cli/program/command-registry.ts b/src/cli/program/command-registry.ts index 324b5692893..16416c87e0a 100644 --- a/src/cli/program/command-registry.ts +++ b/src/cli/program/command-registry.ts @@ -83,7 +83,7 @@ const coreEntries: CoreCliEntry[] = [ { name: "config", description: - "Non-interactive config helpers (get/set/unset/file). Default: starts setup wizard.", + "Non-interactive config helpers (get/set/unset/file/validate). Default: starts setup wizard.", hasSubcommands: true, }, ], diff --git a/src/config/io.compat.test.ts b/src/config/io.compat.test.ts index dbdfee7280c..f8cf21ea43b 100644 --- a/src/config/io.compat.test.ts +++ b/src/config/io.compat.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createConfigIO } from "./io.js"; async function withTempHome(run: (home: string) => Promise): Promise { @@ -137,4 +137,33 @@ describe("config io paths", () => { expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]); }); }); + + it("logs invalid config path details and returns empty config", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".openclaw"); + await fs.mkdir(configDir, { recursive: true }); + const configPath = path.join(configDir, "openclaw.json"); + await fs.writeFile( + configPath, + JSON.stringify({ gateway: { port: "not-a-number" } }, null, 2), + ); + + const logger = { + warn: vi.fn(), + error: vi.fn(), + }; + + const io = createConfigIO({ + env: {} as NodeJS.ProcessEnv, + homedir: () => home, + logger, + }); + + expect(io.loadConfig()).toEqual({}); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining(`Invalid config at ${configPath}:\\n`), + ); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("- gateway.port:")); + }); + }); }); diff --git a/src/config/io.ts b/src/config/io.ts index cf030e11b75..9a051249221 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -720,7 +720,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { loggedInvalidConfigs.add(configPath); deps.logger.error(`Invalid config at ${configPath}:\\n${details}`); } - const error = new Error("Invalid config"); + const error = new Error(`Invalid config at ${configPath}:\n${details}`); (error as { code?: string; details?: string }).code = "INVALID_CONFIG"; (error as { code?: string; details?: string }).details = details; throw error;