feat(config): add openclaw config validate and improve startup error messages (#31220)
Merged via squash. Prepared head SHA: 4598f2a541f0bde300a096ef51638408d273c4bd Co-authored-by: Sid-Qin <201593046+Sid-Qin@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
This commit is contained in:
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
|
|||||||
|
|
||||||
### Changes
|
### 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -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:
|
read_when:
|
||||||
- You want to read or edit config non-interactively
|
- You want to read or edit config non-interactively
|
||||||
title: "config"
|
title: "config"
|
||||||
@@ -7,8 +7,8 @@ title: "config"
|
|||||||
|
|
||||||
# `openclaw config`
|
# `openclaw config`
|
||||||
|
|
||||||
Config helpers: get/set/unset values by path and print the active config file.
|
Config helpers: get/set/unset/validate values by path and print the active
|
||||||
Run without a subcommand to open
|
config file. Run without a subcommand to open
|
||||||
the configure wizard (same as `openclaw configure`).
|
the configure wizard (same as `openclaw configure`).
|
||||||
|
|
||||||
## Examples
|
## 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.defaults.heartbeat.every "2h"
|
||||||
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
openclaw config set agents.list[0].tools.exec.node "node-id-or-name"
|
||||||
openclaw config unset tools.web.search.apiKey
|
openclaw config unset tools.web.search.apiKey
|
||||||
|
openclaw config validate
|
||||||
|
openclaw config validate --json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Paths
|
## 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).
|
- `config file`: Print the active config file path (resolved from `OPENCLAW_CONFIG_PATH` or default location).
|
||||||
|
|
||||||
Restart the gateway after edits.
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -380,7 +380,7 @@ Interactive configuration wizard (models, channels, skills, gateway).
|
|||||||
|
|
||||||
### `config`
|
### `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.
|
subcommand launches the wizard.
|
||||||
|
|
||||||
Subcommands:
|
Subcommands:
|
||||||
@@ -389,6 +389,8 @@ Subcommands:
|
|||||||
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
- `config set <path> <value>`: set a value (JSON5 or raw string).
|
||||||
- `config unset <path>`: remove a value.
|
- `config unset <path>`: remove a value.
|
||||||
- `config file`: print the active config file path.
|
- `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`
|
### `doctor`
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ function setSnapshot(resolved: OpenClawConfig, config: OpenClawConfig) {
|
|||||||
mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
|
mockReadConfigFileSnapshot.mockResolvedValueOnce(buildSnapshot({ resolved, config }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setSnapshotOnce(snapshot: ConfigFileSnapshot) {
|
||||||
|
mockReadConfigFileSnapshot.mockResolvedValueOnce(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
|
let registerConfigCli: typeof import("./config-cli.js").registerConfigCli;
|
||||||
|
|
||||||
async function runConfigCommand(args: string[]) {
|
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", () => {
|
describe("config set parsing flags", () => {
|
||||||
it("falls back to raw string when parsing fails and strict mode is off", async () => {
|
it("falls back to raw string when parsing fails and strict mode is off", async () => {
|
||||||
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
const resolved: OpenClawConfig = { gateway: { port: 18789 } };
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import JSON5 from "json5";
|
import JSON5 from "json5";
|
||||||
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
import { readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
|
import { CONFIG_PATH } from "../config/paths.js";
|
||||||
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
import { isBlockedObjectKey } from "../config/prototype-keys.js";
|
||||||
import { redactConfigObject } from "../config/redact-snapshot.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 type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
@@ -15,6 +16,10 @@ type PathSegment = string;
|
|||||||
type ConfigSetParseOpts = {
|
type ConfigSetParseOpts = {
|
||||||
strictJson?: boolean;
|
strictJson?: boolean;
|
||||||
};
|
};
|
||||||
|
type ConfigIssue = {
|
||||||
|
path: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
|
const OLLAMA_API_KEY_PATH: PathSegment[] = ["models", "providers", "ollama", "apiKey"];
|
||||||
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
|
const OLLAMA_PROVIDER_PATH: PathSegment[] = ["models", "providers", "ollama"];
|
||||||
@@ -97,6 +102,21 @@ function hasOwnPathKey(value: Record<string, unknown>, key: string): boolean {
|
|||||||
return Object.prototype.hasOwnProperty.call(value, key);
|
return Object.prototype.hasOwnProperty.call(value, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeConfigIssues(issues: ReadonlyArray<ConfigIssue>): ConfigIssue[] {
|
||||||
|
return issues.map((issue) => ({
|
||||||
|
path: issue.path || "<root>",
|
||||||
|
message: issue.message,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConfigIssueLines(issues: ReadonlyArray<ConfigIssue>, 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 {
|
function validatePathSegments(path: PathSegment[]): void {
|
||||||
for (const segment of path) {
|
for (const segment of path) {
|
||||||
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
if (!isIndexSegment(segment) && isBlockedObjectKey(segment)) {
|
||||||
@@ -229,10 +249,10 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
runtime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
||||||
for (const issue of snapshot.issues) {
|
for (const line of formatConfigIssueLines(snapshot.issues, "-")) {
|
||||||
runtime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
runtime.error(line);
|
||||||
}
|
}
|
||||||
runtime.error(`Run \`${formatCliCommand("openclaw doctor")}\` to repair, then retry.`);
|
runtime.error(formatDoctorHint("to repair, then retry."));
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return snapshot;
|
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) {
|
export function registerConfigCli(program: Command) {
|
||||||
const cmd = program
|
const cmd = program
|
||||||
.command("config")
|
.command("config")
|
||||||
.description(
|
.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(
|
.addHelpText(
|
||||||
"after",
|
"after",
|
||||||
@@ -408,4 +479,12 @@ export function registerConfigCli(program: Command) {
|
|||||||
.action(async () => {
|
.action(async () => {
|
||||||
await runConfigFile({});
|
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) });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const coreEntries: CoreCliEntry[] = [
|
|||||||
{
|
{
|
||||||
name: "config",
|
name: "config",
|
||||||
description:
|
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,
|
hasSubcommands: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { createConfigIO } from "./io.js";
|
import { createConfigIO } from "./io.js";
|
||||||
|
|
||||||
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
|
async function withTempHome(run: (home: string) => Promise<void>): Promise<void> {
|
||||||
@@ -137,4 +137,33 @@ describe("config io paths", () => {
|
|||||||
expect(cfg.agents?.list?.[0]?.tools?.exec?.safeBinTrustedDirs).toEqual(["/ops/bin"]);
|
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:"));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -720,7 +720,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) {
|
|||||||
loggedInvalidConfigs.add(configPath);
|
loggedInvalidConfigs.add(configPath);
|
||||||
deps.logger.error(`Invalid config at ${configPath}:\\n${details}`);
|
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 }).code = "INVALID_CONFIG";
|
||||||
(error as { code?: string; details?: string }).details = details;
|
(error as { code?: string; details?: string }).details = details;
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user