fix(config): improve validation error messages with line numbers, bracket paths, and received values (#106526)

* fix(config): improve validation error messages with line numbers, bracket paths, and received values

- Add JSON5-aware path navigator (issue-location.ts) that resolves line
  numbers and received values from raw config text without a full parser
- Add formatConfigIssuePath() for bracket notation (agents.list[3])
- Add appendReceivedValueHint() for safe value display (got: "none")
- Add attachConfigIssueDiagnostics() to enrich issues with location info
- Wire enrichment into runConfigValidate() and loadValidConfig() in config-cli.ts
- Add resolveIssueLocationPrefix() to issue-format.ts for sourceFile:line prefix
- Sensitive paths and secret refs are never leaked in received value hints
- Paths in 'd files gracefully degrade (no line number shown)

Closes #104854

* fix(config): type fixes from tsgo:core validation

- Add sourceFile to ConfigIssueFormatOptions (used in resolveIssueLocationPrefix)
- Fix readKey regex test for undefined raw[c.pos]

* fix(config): satisfy eslint(curly) and formatting for issue-location.ts

- Add braces to all single-line if/while/for statements
- Run oxfmt for consistent formatting

* fix(config): trim issue-location.ts to 500 lines for LOC ratchet

* fix(config): fix formatConfigIssuePath ternary bug from trimming

* fix(config): trim issue-location.ts to 469 lines for LOC ratchet

* fix(config): format issue-location.test.ts

* fix(config): document JSON5 subset and add 20 edge case tests

- Add explicit supported-subset documentation to issue-location.ts header
- Add 20 tests covering hex numbers, leading decimals, Infinity, NaN,
  null/bool, trailing commas, deep nesting, unicode escapes, multi-line
  strings, unicode keys, escaped quotes, numeric separators, block comments,
  mixed quotes, empty objects/arrays, and graceful degradation

* fix(config): address autoreview findings on JSON5 docs and tests

- Remove numeric separator claim (not valid JSON5)
- Remove 'None known' unsupported-syntax claim, document known limitations
- Restructure scalar tests to exercise skipVal (resolve sibling after exotic value)
- Remove numeric separator test

* fix(config): constrain received value diagnostics

* fix(config): preserve JSON5 diagnostic locations

* fix(config): preserve validation path ownership

* fix(config): redact dotted plugin paths

* test(config): avoid diagnostic fixture shadowing

* refactor(config): privatize diagnostic helpers

---------

Co-authored-by: Erick Kinnee <erick@ekinnee.dev>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Erick Kinnee
2026-07-16 23:01:30 -05:00
committed by GitHub
parent 8d5e39afcd
commit 135aa7a002
9 changed files with 1091 additions and 31 deletions

View File

@@ -310,14 +310,18 @@ function makeInvalidSnapshot(params: {
issues: ConfigFileSnapshot["issues"];
warnings?: ConfigFileSnapshot["warnings"];
path?: string;
raw?: string;
parsed?: unknown;
sourceConfig?: OpenClawConfig;
}): ConfigFileSnapshot {
const parsed = params.parsed ?? {};
return {
path: params.path ?? "/tmp/custom-openclaw.json",
exists: true,
raw: "{}",
parsed: {},
sourceConfig: {},
resolved: {},
raw: params.raw ?? "{}",
parsed,
sourceConfig: params.sourceConfig ?? (parsed as OpenClawConfig),
resolved: parsed as OpenClawConfig,
valid: false,
runtimeConfig: {},
config: {},
@@ -1165,6 +1169,47 @@ describe("config cli", () => {
expect(mockLog).not.toHaveBeenCalled();
});
it("prints line numbers, bracket array paths, and safe received values", async () => {
const parsed = {
agents: {
list: [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d", tools: { profile: "none" } }],
},
};
const raw = [
"{",
' "agents": {',
' "list": [',
' { "id": "a" },',
' { "id": "b" },',
' { "id": "c" },',
' { "id": "d", "tools": { "profile": "none" } }',
" ]",
" }",
"}",
].join("\n");
setSnapshotOnce(
makeInvalidSnapshot({
raw,
parsed,
path: "/tmp/openclaw.json",
issues: [
{
path: "agents.list.3.tools.profile",
pathSegments: ["agents", "list", 3, "tools", "profile"],
message: 'Invalid input (allowed: "minimal", "coding", "messaging", "full")',
allowedValues: ["minimal", "coding", "messaging", "full"],
},
],
}),
);
await expect(runConfigCommand(["config", "validate"])).rejects.toThrow("__exit__:1");
expectErrorIncludes(
'openclaw.json:7 — agents.list[3].tools.profile: Invalid input (allowed: "minimal", "coding", "messaging", "full"), got: "none"',
);
});
it("returns machine-readable JSON with --json for invalid config", async () => {
setSnapshotOnce(
makeInvalidSnapshot({

View File

@@ -19,6 +19,7 @@ import {
} from "../config/config.js";
import { AUTO_MANAGED_CONFIG_META_PATHS } from "../config/io.meta.js";
import { formatConfigIssueLines, normalizeConfigIssues } from "../config/issue-format.js";
import { attachConfigIssueDiagnostics } from "../config/issue-location.js";
import {
normalizeAgentModelMapForConfig,
normalizeAgentModelRefForConfig,
@@ -952,7 +953,15 @@ async function loadValidConfig(runtime: RuntimeEnv = defaultRuntime) {
return snapshot;
}
runtime.error(`OpenClaw config is invalid: ${shortenHomePath(snapshot.path)}`);
for (const line of formatConfigIssueLines(snapshot.issues, "-", { normalizeRoot: true })) {
const displayIssues = attachConfigIssueDiagnostics(snapshot.issues, {
raw: snapshot.raw,
parsed: snapshot.parsed,
effective: snapshot.sourceConfig,
configPath: snapshot.path,
formatPathForDisplay: true,
includeReceivedValueHint: true,
});
for (const line of formatConfigIssueLines(displayIssues, "-", { normalizeRoot: true })) {
runtime.error(line);
}
runtime.error(formatInvalidConfigRepairHint(snapshot, "to repair, then retry."));
@@ -2626,8 +2635,18 @@ async function runConfigValidate(opts: { json?: boolean; runtime?: RuntimeEnv }
if (opts.json) {
writeRuntimeJson(runtime, { valid: false, path: outputPath, issues });
} else {
const displayIssues = attachConfigIssueDiagnostics(issues, {
raw: snapshot.raw,
parsed: snapshot.parsed,
effective: snapshot.sourceConfig,
configPath: snapshot.path,
formatPathForDisplay: true,
includeReceivedValueHint: true,
});
runtime.error(danger(`OpenClaw config is invalid: ${shortPath}`));
for (const line of formatConfigIssueLines(issues, danger("×"), { normalizeRoot: true })) {
for (const line of formatConfigIssueLines(displayIssues, danger("×"), {
normalizeRoot: true,
})) {
runtime.error(` ${line}`);
}
runtime.error("");

View File

@@ -8,6 +8,21 @@ import {
} from "./issue-format.js";
describe("config issue format", () => {
it("formats issue lines with source locations", () => {
expect(
formatConfigIssueLine(
{
path: "agents.list[3].tools.profile",
message: 'Invalid input, got: "none"',
line: 247,
sourceFile: "openclaw.json",
},
"×",
{ normalizeRoot: true },
),
).toBe('× openclaw.json:247 — agents.list[3].tools.profile: Invalid input, got: "none"');
});
it("formats issue lines with and without markers", () => {
expect(formatConfigIssueLine({ path: "", message: "broken" }, "-")).toBe("- : broken");
expect(
@@ -55,15 +70,18 @@ describe("config issue format", () => {
});
it("normalizes issue collections for machine output", () => {
expect(
normalizeConfigIssues([
{
path: "update.channel",
message: "invalid",
allowedValues: [],
allowedValuesHiddenCount: 2,
},
]),
).toEqual([{ path: "update.channel", message: "invalid" }]);
const issues = normalizeConfigIssues([
{
path: "update.channel",
pathSegments: ["update", "channel"],
message: "invalid",
allowedValues: [],
allowedValuesHiddenCount: 2,
},
]);
expect(issues).toEqual([{ path: "update.channel", message: "invalid" }]);
expect(issues[0]?.pathSegments).toEqual(["update", "channel"]);
expect(JSON.stringify(issues)).not.toContain("pathSegments");
});
});

View File

@@ -5,10 +5,13 @@ import type { ConfigValidationIssue } from "./types.js";
type ConfigIssueLineInput = {
path?: string | null;
message: string;
line?: number;
sourceFile?: string;
};
type ConfigIssueFormatOptions = {
normalizeRoot?: boolean;
sourceFile?: string;
};
type ConfigIssueSummaryOptions = ConfigIssueFormatOptions & {
@@ -27,7 +30,7 @@ function normalizeConfigIssuePath(path: string | null | undefined): string {
/** Return the public config issue shape with a normalized path and non-empty allowed values. */
function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIssue {
const hasAllowedValues = Array.isArray(issue.allowedValues) && issue.allowedValues.length > 0;
return {
const normalized: ConfigValidationIssue = {
path: normalizeConfigIssuePath(issue.path),
message: issue.message,
...(hasAllowedValues ? { allowedValues: issue.allowedValues } : {}),
@@ -37,6 +40,13 @@ function normalizeConfigIssue(issue: ConfigValidationIssue): ConfigValidationIss
? { allowedValuesHiddenCount: issue.allowedValuesHiddenCount }
: {}),
};
if (issue.pathSegments) {
Object.defineProperty(normalized, "pathSegments", {
value: issue.pathSegments,
enumerable: false,
});
}
return normalized;
}
/** Normalize a batch of config validation issues for display or JSON output. */
@@ -46,6 +56,22 @@ export function normalizeConfigIssues(
return issues.map((issue) => normalizeConfigIssue(issue));
}
function resolveIssueLocationPrefix(
issue: ConfigIssueLineInput,
opts?: ConfigIssueFormatOptions,
): string {
const sourceFile =
typeof issue.sourceFile === "string" && issue.sourceFile.trim()
? issue.sourceFile.trim()
: typeof opts?.sourceFile === "string" && opts.sourceFile.trim()
? opts.sourceFile.trim()
: "";
if (!sourceFile || typeof issue.line !== "number" || issue.line <= 0) {
return "";
}
return `${sanitizeTerminalText(sourceFile)}:${issue.line}`;
}
function resolveIssuePathForLine(
path: string | null | undefined,
opts?: ConfigIssueFormatOptions,
@@ -66,9 +92,10 @@ export function formatConfigIssueLine(
opts?: ConfigIssueFormatOptions,
): string {
const prefix = marker ? `${marker} ` : "";
const locationPrefix = resolveIssueLocationPrefix(issue, opts);
const path = sanitizeTerminalText(resolveIssuePathForLine(issue.path, opts));
const message = sanitizeTerminalText(issue.message);
return `${prefix}${path}: ${message}`;
return `${prefix}${locationPrefix}${path}: ${message}`;
}
/** Format config issues as terminal-safe lines with a shared marker prefix. */

View File

@@ -0,0 +1,558 @@
import JSON5 from "json5";
import { describe, expect, it } from "vitest";
import { attachConfigIssueDiagnostics } from "./issue-location.js";
type PathSegment = string | number;
function formatConfigIssuePath(pathSegments: PathSegment[]): string {
return (
attachConfigIssueDiagnostics(
[{ path: pathSegments.join("."), pathSegments, message: "Invalid input" }],
{ raw: null, parsed: {}, effective: {}, formatPathForDisplay: true },
)[0]?.path ?? ""
);
}
function resolveConfigIssueLineInRaw(raw: string, pathSegments: PathSegment[]): number | undefined {
let parsed: unknown = {};
try {
parsed = JSON5.parse(raw);
} catch {
// Malformed or empty text cannot own a source location.
}
return attachConfigIssueDiagnostics(
[{ path: pathSegments.join("."), pathSegments, message: "Invalid input" }],
{ raw, parsed, effective: parsed },
)[0]?.line;
}
function appendReceivedValueHint(message: string, pathValue: string, value: unknown): string {
const pathSegments = pathValue.split(".");
const root: Record<string, unknown> = {};
let current = root;
for (const segment of pathSegments.slice(0, -1)) {
const child: Record<string, unknown> = {};
current[segment] = child;
current = child;
}
current[pathSegments.at(-1) ?? ""] = value;
return (
attachConfigIssueDiagnostics([{ path: pathValue, pathSegments, message }], {
raw: JSON5.stringify(root),
parsed: root,
effective: root,
includeReceivedValueHint: true,
})[0]?.message ?? message
);
}
describe("formatConfigIssuePath", () => {
it("formats numeric segments with bracket notation", () => {
expect(formatConfigIssuePath(["agents", "list", 3, "tools", "profile"])).toBe(
"agents.list[3].tools.profile",
);
});
it("handles consecutive numeric indices", () => {
expect(formatConfigIssuePath(["a", 0, "b", 1])).toBe("a[0].b[1]");
});
it("returns empty string for empty path", () => {
expect(formatConfigIssuePath([])).toBe("");
});
it("handles all-string path", () => {
expect(formatConfigIssuePath(["foo", "bar", "baz"])).toBe("foo.bar.baz");
});
});
describe("resolveConfigIssueLineInRaw", () => {
it("resolves line number for nested array object values", () => {
const raw = [
"{",
' "agents": {',
' "list": [',
" {",
' "id": "main"',
" },",
" {",
' "tools": {',
' "profile": "none"',
" }",
" }",
" ]",
" }",
"}",
].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["agents", "list", 1, "tools", "profile"])).toBe(9);
});
it("resolves line number for top-level key", () => {
const raw = ["{", ' "update": {', ' "channel": "nightly"', " }", "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["update", "channel"])).toBe(3);
});
it("returns undefined for path not in raw text", () => {
const raw = ["{", "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["nonexistent"])).toBeUndefined();
});
it("handles JSON5 comments", () => {
const raw = ["{", " // comment", ' "key": "value"', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(3);
});
it("handles comments between unquoted keys and colons", () => {
const raw = ["{", " key // comment", ' : "value"', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(3);
});
it("handles comments directly after scalar values", () => {
const raw = ["{", " ignored: 1 // comment", ' , target: "bad"', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["target"])).toBe(3);
});
it("uses the active value when an object repeats a key", () => {
const raw = ["{", ' key: "old",', ' key: "bad"', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(3);
});
it("handles single-quoted strings", () => {
const raw = ["{", " 'key': 'value'", "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(2);
});
it("handles hex numbers as values", () => {
const raw = ["{", ' "a": 0x1A,', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles leading decimal numbers", () => {
const raw = ["{", ' "a": .5,', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles Infinity value", () => {
const raw = ["{", ' "a": Infinity,', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles NaN value", () => {
const raw = ["{", ' "a": NaN,', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles null and boolean values", () => {
const raw = ["{", ' "a": null, "b": true, "c": false', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a"])).toBe(2);
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(2);
expect(resolveConfigIssueLineInRaw(raw, ["c"])).toBe(2);
});
it("handles trailing commas in objects", () => {
const raw = ["{", ' "a": 1,', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a"])).toBe(2);
});
it("handles trailing commas in arrays", () => {
const raw = ["{", ' "a": [1, 2,]', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a"])).toBe(2);
});
it("handles deeply nested arrays", () => {
const raw = ["{", ' "a": { "b": { "c": [1, [2, [3]]] } } }', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a", "b", "c", 1, 0])).toBe(2);
});
it("handles unicode escape sequences in strings", () => {
const raw = ["{", ' "a": "hello \\u0041",', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles multi-line string continuation", () => {
const raw = ["{", ' "a": "hello \\', 'world",', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(4);
});
it("handles unicode keys", () => {
const raw = ["{", ' "café": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["café"])).toBe(2);
});
it("handles escaped quotes in strings", () => {
const raw = ["{", ' "a": "hello \\"world\\"",', ' "b": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["b"])).toBe(3);
});
it("handles block comments before keys", () => {
const raw = ["{", " /* comment */", ' "key": "value"', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(3);
});
it("handles mixed single/double quotes", () => {
const raw = ["{", " 'key': \"value\"", "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["key"])).toBe(2);
});
it("handles empty object value", () => {
const raw = ["{", ' "a": {}', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a"])).toBe(2);
});
it("handles empty array value", () => {
const raw = ["{", ' "a": []', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["a"])).toBe(2);
});
it("handles array index navigation with nested objects", () => {
const raw = [
"{",
' "agents": {',
' "list": [',
" {",
' "id": "main"',
" },",
" {",
' "tools": {',
' "profile": "none"',
" }",
" }",
" ]",
" }",
"}",
].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["agents", "list", 1, "tools", "profile"])).toBe(9);
});
it("gracefully degrades for unresolvable paths", () => {
const raw = ["{", ' "a": 1', "}"].join("\n");
expect(resolveConfigIssueLineInRaw(raw, ["nonexistent"])).toBeUndefined();
expect(resolveConfigIssueLineInRaw(raw, ["a", "b"])).toBeUndefined();
expect(resolveConfigIssueLineInRaw(raw, ["a", 0])).toBeUndefined();
});
it("handles empty raw text", () => {
expect(resolveConfigIssueLineInRaw("", ["a"])).toBeUndefined();
expect(resolveConfigIssueLineInRaw(" ", ["a"])).toBeUndefined();
});
});
describe("appendReceivedValueHint", () => {
it("appends got: for simple values", () => {
expect(
appendReceivedValueHint(
'Invalid input (allowed: "minimal", "coding")',
"agents.list[0].tools.profile",
"none",
),
).toBe('Invalid input (allowed: "minimal", "coding"), got: "none"');
});
it("skips when message already mentions received", () => {
expect(appendReceivedValueHint("expected string, received number", "gateway.port", 18789)).toBe(
"expected string, received number",
);
});
it("skips sensitive paths", () => {
expect(appendReceivedValueHint("invalid token", "channels.telegram.botToken", "abc123")).toBe(
"invalid token",
);
});
it("skips secret ref objects", () => {
expect(
appendReceivedValueHint("invalid input", "models.providers.openai.apiKey", {
source: "env",
provider: "default",
id: "OPENAI_API_KEY",
}),
).toBe("invalid input");
});
it("skips object values", () => {
expect(appendReceivedValueHint("invalid input", "some.path", { nested: true })).toBe(
"invalid input",
);
});
it("skips undefined values", () => {
expect(appendReceivedValueHint("invalid input", "some.path", undefined)).toBe("invalid input");
});
it("skips when message already has got:", () => {
expect(appendReceivedValueHint("already got: something", "some.path", "value")).toBe(
"already got: something",
);
});
it.each([
[Number.NaN, "NaN"],
[Number.POSITIVE_INFINITY, "Infinity"],
[Number.NEGATIVE_INFINITY, "-Infinity"],
[-0, "-0"],
])("renders JSON5 number %s without coercing it to null", (value, label) => {
expect(appendReceivedValueHint("invalid input", "some.path", value)).toBe(
`invalid input, got: ${label}`,
);
});
});
describe("attachConfigIssueDiagnostics", () => {
const issue = (
pathSegments: Array<string | number>,
message: string,
extra: { allowedValues?: string[] } = {},
) => ({ path: pathSegments.join("."), pathSegments, message, ...extra });
const raw = [
"{",
' "agents": {',
' "list": [',
' { "tools": { "profile": "none" } }',
" ]",
" }",
"}",
].join("\n");
const parsed = {
agents: { list: [{ tools: { profile: "none" } }] },
};
it("preserves internal path by default", () => {
const issues = attachConfigIssueDiagnostics(
[
issue(
["agents", "list", 0, "tools", "profile"],
'Invalid input (allowed: "minimal", "coding")',
{
allowedValues: ["minimal", "coding"],
},
),
],
{ raw, parsed, effective: parsed, configPath: "/tmp/openclaw.json" },
);
expect(issues[0]?.path).toBe("agents.list.0.tools.profile");
expect(issues[0]?.message).toBe('Invalid input (allowed: "minimal", "coding")');
expect(issues[0]?.line).toBe(4);
expect(issues[0]?.sourceFile).toBe("openclaw.json");
});
it("formats display path when requested", () => {
const issues = attachConfigIssueDiagnostics(
[
issue(
["agents", "list", 0, "tools", "profile"],
'Invalid input (allowed: "minimal", "coding")',
{
allowedValues: ["minimal", "coding"],
},
),
],
{
raw,
parsed,
effective: parsed,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]?.path).toBe("agents.list[0].tools.profile");
expect(issues[0]?.message).toContain('got: "none"');
expect(issues[0]?.line).toBe(4);
});
it("handles empty raw gracefully", () => {
const issues = attachConfigIssueDiagnostics([issue(["foo"], "error")], {
raw: null,
parsed: {},
effective: {},
configPath: "/tmp/openclaw.json",
});
expect(issues[0]?.line).toBeUndefined();
expect(issues[0]?.sourceFile).toBeUndefined();
});
it("handles $include'd paths gracefully (navigator returns undefined)", () => {
const issues = attachConfigIssueDiagnostics(
[
issue(
["models", "providers", "openai", "api"],
'Invalid input (allowed: "openai-chatgpt")',
),
],
{
raw: ["{", ' "$include": "./models.json"', "}"].join("\n"),
parsed: { models: { providers: { openai: { api: "bad" } } } },
effective: { models: { providers: { openai: { api: "bad" } } } },
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
// Path exists in parsed but not in raw — no line number, no received value
expect(issues[0]?.line).toBeUndefined();
expect(issues[0]?.sourceFile).toBeUndefined();
expect(issues[0]?.message).toBe('Invalid input (allowed: "openai-chatgpt")');
});
it("preserves numeric record keys (not array indices)", () => {
const issues = attachConfigIssueDiagnostics(
[issue(["plugins", "entries", "123", "config", "mode"], 'Invalid input (allowed: "good")')],
{
raw: [
"{",
' "plugins": {',
' "entries": {',
' "123": { "config": { "mode": "bad" } }',
" }",
" }",
"}",
].join("\n"),
parsed: { plugins: { entries: { "123": { config: { mode: "bad" } } } } },
effective: { plugins: { entries: { "123": { config: { mode: "bad" } } } } },
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]?.path).toBe("plugins.entries.123.config.mode");
expect(issues[0]?.message).toBe('Invalid input (allowed: "good")');
expect(issues[0]?.line).toBe(4);
});
it("preserves non-canonical numeric record keys", () => {
const recordConfig = { records: { "01": { mode: "bad" }, "1": { mode: "good" } } };
const issues = attachConfigIssueDiagnostics(
[issue(["records", "01", "mode"], "Invalid input")],
{
raw: '{ records: { "01": { mode: "bad" }, "1": { mode: "good" } } }',
parsed: recordConfig,
effective: recordConfig,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]).toMatchObject({
path: "records.01.mode",
message: 'Invalid input, got: "bad"',
line: 1,
});
});
it("uses structured paths to distinguish dotted keys from nested keys", () => {
const dottedConfig = { "foo.bar": "literal", foo: { bar: "nested" } };
const params = {
raw: ['{ "foo.bar": "literal",', ' foo: { bar: "nested" } }'].join("\n"),
parsed: dottedConfig,
effective: dottedConfig,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
};
expect(
attachConfigIssueDiagnostics([issue(["foo.bar"], "Invalid input")], params)[0],
).toMatchObject({
message: 'Invalid input, got: "literal"',
line: 1,
});
expect(
attachConfigIssueDiagnostics([issue(["foo", "bar"], "Invalid input")], params)[0],
).toMatchObject({
message: 'Invalid input, got: "nested"',
line: 2,
});
});
it("does not let existing values steal missing dotted or nested paths", () => {
const dottedConfig = { "foo.bar": "literal", foo: {} };
const params = {
raw: ['{ "foo.bar": "literal",', " foo: {} }"].join("\n"),
parsed: dottedConfig,
effective: dottedConfig,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
};
expect(attachConfigIssueDiagnostics([issue(["foo", "bar"], "Required")], params)[0]).toEqual(
issue(["foo", "bar"], "Required"),
);
const nestedOnly = { foo: { bar: "nested" } };
expect(
attachConfigIssueDiagnostics([issue(["foo.bar"], "Required")], {
...params,
raw: '{ foo: { bar: "nested" } }',
parsed: nestedOnly,
effective: nestedOnly,
})[0],
).toEqual(issue(["foo.bar"], "Required"));
});
it("omits values changed by environment substitution", () => {
const envRaw = raw.replace('"none"', '"${PROFILE}"');
const issues = attachConfigIssueDiagnostics(
[issue(["agents", "list", 0, "tools", "profile"], "Invalid input")],
{
raw: envRaw,
parsed: { agents: { list: [{ tools: { profile: "${PROFILE}" } }] } },
effective: { agents: { list: [{ tools: { profile: "none" } }] } },
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]?.message).toBe("Invalid input");
expect(issues[0]?.line).toBe(4);
});
it("omits arbitrary plugin-owned values", () => {
const pluginConfig = {
plugins: { entries: { custom: { config: { accessCode: "private" } } } },
};
const issues = attachConfigIssueDiagnostics(
[issue(["plugins", "entries", "custom", "config", "accessCode"], "Invalid input")],
{
raw: '{ plugins: { entries: { custom: { config: { accessCode: "private" } } } } }',
parsed: pluginConfig,
effective: pluginConfig,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]?.message).toBe("Invalid input");
expect(issues[0]?.line).toBe(1);
});
it("omits plugin-owned values when the plugin id contains dots", () => {
const pluginConfig = {
plugins: { entries: { "vendor.plugin": { config: { sessionValue: "private" } } } },
};
const issues = attachConfigIssueDiagnostics(
[issue(["plugins", "entries", "vendor.plugin", "config", "sessionValue"], "Invalid input")],
{
raw: '{ plugins: { entries: { "vendor.plugin": { config: { sessionValue: "private" } } } } }',
parsed: pluginConfig,
effective: pluginConfig,
configPath: "/tmp/openclaw.json",
formatPathForDisplay: true,
includeReceivedValueHint: true,
},
);
expect(issues[0]?.message).toBe("Invalid input");
expect(issues[0]?.line).toBe(1);
});
});

View File

@@ -0,0 +1,369 @@
import path from "node:path";
import JSON5 from "json5";
import { isSensitiveConfigPath } from "./sensitive-paths.js";
import type { ConfigValidationIssue } from "./types.js";
import { isSecretRef } from "./types.secrets.js";
type ConfigIssuePathSegment = string | number;
type Cursor = { pos: number };
function skipTrivia(raw: string, cursor: Cursor): void {
while (cursor.pos < raw.length) {
const char = raw[cursor.pos];
if (/\s/u.test(char ?? "")) {
cursor.pos++;
continue;
}
if (char === "/" && raw[cursor.pos + 1] === "/") {
cursor.pos += 2;
while (cursor.pos < raw.length && !/[\n\r\u2028\u2029]/u.test(raw[cursor.pos] ?? "")) {
cursor.pos++;
}
continue;
}
if (char === "/" && raw[cursor.pos + 1] === "*") {
const close = raw.indexOf("*/", cursor.pos + 2);
cursor.pos = close === -1 ? raw.length : close + 2;
continue;
}
return;
}
}
function scanQuoted(raw: string, cursor: Cursor): void {
const quote = raw[cursor.pos++];
while (cursor.pos < raw.length) {
const char = raw[cursor.pos++];
if (char === "\\") {
cursor.pos++;
} else if (char === quote) {
return;
}
}
}
function consume(raw: string, cursor: Cursor, expected: string): boolean {
skipTrivia(raw, cursor);
if (raw[cursor.pos] !== expected) {
return false;
}
cursor.pos++;
return true;
}
function readObjectKey(raw: string, cursor: Cursor): string | null {
skipTrivia(raw, cursor);
const start = cursor.pos;
const char = raw[cursor.pos];
if (char === '"' || char === "'") {
scanQuoted(raw, cursor);
} else {
while (
cursor.pos < raw.length &&
!/[\s:]/u.test(raw[cursor.pos] ?? "") &&
!(raw[cursor.pos] === "/" && /[/*]/u.test(raw[cursor.pos + 1] ?? ""))
) {
cursor.pos++;
}
}
if (cursor.pos === start) {
return null;
}
const token = raw.slice(start, cursor.pos);
try {
if (char === '"' || char === "'") {
const parsed = JSON5.parse(token);
return typeof parsed === "string" ? parsed : null;
}
const parsed = JSON5.parse(`{${token}:null}`) as Record<string, unknown>;
return Object.keys(parsed)[0] ?? null;
} catch {
return null;
}
}
function skipValue(raw: string, cursor: Cursor): void {
skipTrivia(raw, cursor);
const char = raw[cursor.pos];
if (char === '"' || char === "'") {
scanQuoted(raw, cursor);
return;
}
if (char === "{") {
cursor.pos++;
while (cursor.pos < raw.length) {
skipTrivia(raw, cursor);
if (raw[cursor.pos] === "}") {
cursor.pos++;
return;
}
if (readObjectKey(raw, cursor) === null || !consume(raw, cursor, ":")) {
return;
}
skipValue(raw, cursor);
skipTrivia(raw, cursor);
if (raw[cursor.pos] === ",") {
cursor.pos++;
}
}
return;
}
if (char === "[") {
cursor.pos++;
while (cursor.pos < raw.length) {
skipTrivia(raw, cursor);
if (raw[cursor.pos] === "]") {
cursor.pos++;
return;
}
skipValue(raw, cursor);
skipTrivia(raw, cursor);
if (raw[cursor.pos] === ",") {
cursor.pos++;
}
}
return;
}
while (
cursor.pos < raw.length &&
!/[,}\]\s]/u.test(raw[cursor.pos] ?? "") &&
!(raw[cursor.pos] === "/" && /[/*]/u.test(raw[cursor.pos + 1] ?? ""))
) {
cursor.pos++;
}
}
function locateValueOffset(
raw: string,
cursor: Cursor,
segments: readonly ConfigIssuePathSegment[],
depth: number,
): number | undefined {
const segment = segments[depth];
const isLeaf = depth === segments.length - 1;
if (typeof segment === "number") {
if (!consume(raw, cursor, "[")) {
return undefined;
}
for (let index = 0; cursor.pos < raw.length; index++) {
skipTrivia(raw, cursor);
if (raw[cursor.pos] === "]") {
return undefined;
}
if (index === segment) {
return isLeaf ? cursor.pos : locateValueOffset(raw, cursor, segments, depth + 1);
}
skipValue(raw, cursor);
if (!consume(raw, cursor, ",")) {
return undefined;
}
}
return undefined;
}
if (!consume(raw, cursor, "{")) {
return undefined;
}
let lastMatch: number | undefined;
while (cursor.pos < raw.length) {
skipTrivia(raw, cursor);
if (raw[cursor.pos] === "}") {
return lastMatch;
}
const key = readObjectKey(raw, cursor);
if (key === null || !consume(raw, cursor, ":")) {
return undefined;
}
skipTrivia(raw, cursor);
const valueStart = cursor.pos;
if (key === segment) {
lastMatch = isLeaf
? valueStart
: locateValueOffset(raw, { pos: valueStart }, segments, depth + 1);
}
cursor.pos = valueStart;
skipValue(raw, cursor);
skipTrivia(raw, cursor);
if (raw[cursor.pos] === ",") {
cursor.pos++;
continue;
}
return lastMatch;
}
return lastMatch;
}
function lineAtOffset(raw: string, offset: number): number {
let line = 1;
for (let index = 0; index < offset; index++) {
const char = raw[index];
if (char === "\r") {
line++;
if (raw[index + 1] === "\n") {
index++;
}
} else if (char === "\n" || char === "\u2028" || char === "\u2029") {
line++;
}
}
return line;
}
function formatConfigIssuePath(segments: readonly ConfigIssuePathSegment[]): string {
return segments.reduce<string>(
(result, segment) =>
typeof segment === "number"
? `${result}[${segment}]`
: result
? `${result}.${segment}`
: segment,
"",
);
}
function resolveConfigValueAtPath(
root: unknown,
segments: readonly ConfigIssuePathSegment[],
): unknown {
let current = root;
for (const segment of segments) {
if (typeof segment === "number") {
if (!Array.isArray(current) || segment >= current.length) {
return undefined;
}
current = current[segment];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
function stringifyReceivedValue(value: unknown): string | null {
if (value === undefined) {
return null;
}
if (typeof value === "number" && (!Number.isFinite(value) || Object.is(value, -0))) {
return Object.is(value, -0) ? "-0" : String(value);
}
try {
const serialized = JSON.stringify(value);
if (serialized === undefined) {
return null;
}
return serialized.length > 160 ? `${serialized.slice(0, 157)}...` : serialized;
} catch {
return null;
}
}
function isPluginOwnedConfigPath(
pathValue: string,
pathSegments?: readonly ConfigIssuePathSegment[],
): boolean {
if (pathSegments) {
return (
pathSegments[0] === "channels" ||
(pathSegments[0] === "plugins" &&
pathSegments[1] === "entries" &&
pathSegments[3] === "config")
);
}
return (
pathValue.startsWith("channels.") || /^plugins\.entries\.[^.]+\.config(?:\.|$)/.test(pathValue)
);
}
function shouldOmitReceivedValue(
pathValue: string,
value: unknown,
pathSegments?: readonly ConfigIssuePathSegment[],
): boolean {
return (
value === undefined ||
isSecretRef(value) ||
isSensitiveConfigPath(pathValue) ||
isPluginOwnedConfigPath(pathValue, pathSegments) ||
(typeof value === "object" && value !== null) ||
stringifyReceivedValue(value) === null
);
}
function appendReceivedValueHint(
message: string,
pathValue: string,
value: unknown,
pathSegments?: readonly ConfigIssuePathSegment[],
): string {
if (
shouldOmitReceivedValue(pathValue, value, pathSegments) ||
message.toLowerCase().includes("got:") ||
/\breceived\b/i.test(message)
) {
return message;
}
const label = stringifyReceivedValue(value);
return label ? `${message}, got: ${label}` : message;
}
function resolveConfigIssueLineInRaw(
raw: string,
segments: readonly ConfigIssuePathSegment[],
): number | undefined {
if (segments.length === 0 || raw.trim().length === 0) {
return undefined;
}
const offset = locateValueOffset(raw, { pos: 0 }, segments, 0);
return offset === undefined ? undefined : lineAtOffset(raw, offset);
}
type AttachConfigIssueDiagnosticsParams = {
raw: string | null | undefined;
parsed: unknown;
effective: unknown;
configPath?: string | null;
formatPathForDisplay?: boolean;
includeReceivedValueHint?: boolean;
};
type ConfigIssueDiagnostics = ConfigValidationIssue & {
line?: number;
sourceFile?: string;
};
export function attachConfigIssueDiagnostics(
issues: readonly ConfigValidationIssue[],
params: AttachConfigIssueDiagnosticsParams,
): ConfigIssueDiagnostics[] {
const raw = typeof params.raw === "string" ? params.raw : null;
const sourceFile =
typeof params.configPath === "string" && params.configPath.trim()
? path.basename(params.configPath)
: "openclaw.json";
return issues.map((issue) => {
const segments = issue.pathSegments;
if (!segments || segments.length === 0) {
return issue;
}
const literalValue = resolveConfigValueAtPath(params.parsed, segments);
const effectiveValue = resolveConfigValueAtPath(params.effective, segments);
const line = raw === null ? undefined : resolveConfigIssueLineInRaw(raw, segments);
// Validation follows includes, env substitution, and migrations. Only a
// matching root-file literal is both accurate and safe to echo here.
const canShowReceivedValue = line !== undefined && Object.is(literalValue, effectiveValue);
const message =
params.includeReceivedValueHint && canShowReceivedValue
? appendReceivedValueHint(issue.message, issue.path, effectiveValue, segments)
: issue.message;
return {
...issue,
path: params.formatPathForDisplay ? formatConfigIssuePath(segments) : issue.path,
message,
...(line === undefined ? {} : { line, sourceFile }),
};
});
}

View File

@@ -278,6 +278,8 @@ export type RuntimeConfig = BrandedConfigState<"runtime">;
export type ConfigValidationIssue = {
/** Dot-path to the invalid or legacy config value. */
path: string;
/** Structured validator path used internally for lossless source diagnostics. */
pathSegments?: Array<string | number>;
/** Human-readable validation message. */
message: string;
/** Optional allowed values shown to the operator. */

View File

@@ -27,6 +27,8 @@ describe("config validation allowed-values metadata", () => {
expect(result.ok).toBe(false);
if (!result.ok) {
const issue = requireIssue(result.issues, "update.channel");
expect(issue.pathSegments).toEqual(["update", "channel"]);
expect(JSON.stringify(issue)).not.toContain("pathSegments");
expect(issue.message).toContain('(allowed: "stable", "extended-stable", "beta", "dev")');
expect(issue.allowedValues).toEqual(["stable", "extended-stable", "beta", "dev"]);
expect(issue.allowedValuesHiddenCount).toBe(0);

View File

@@ -207,6 +207,17 @@ function formatConfigPath(segments: readonly ConfigPathSegment[]): string {
return segments.join(".");
}
function withConfigIssuePath(
issue: ConfigValidationIssue,
pathSegments: readonly ConfigPathSegment[],
): ConfigValidationIssue {
Object.defineProperty(issue, "pathSegments", {
value: [...pathSegments],
enumerable: false,
});
return issue;
}
function formatMissingOfficialExternalPluginWarning(
pluginId: string,
opts?: { selectedMissingMemorySlot?: boolean },
@@ -631,7 +642,7 @@ function isRouteTypeMismatchIssue(issue: UnknownIssueRecord): boolean {
function extractBindingsSpecificUnionIssue(
record: UnknownIssueRecord,
parentPath: string,
parentPathSegments: readonly ConfigPathSegment[],
): ConfigValidationIssue | null {
if (!isBindingsIssuePath(toConfigPathSegments(record.path)) || !Array.isArray(record.errors)) {
return null;
@@ -700,11 +711,16 @@ function extractBindingsSpecificUnionIssue(
return null;
}
const subPath = formatConfigPath(toConfigPathSegments(matchingBranchIssue.path));
const fullPath = parentPath && subPath ? `${parentPath}.${subPath}` : parentPath || subPath;
const fullPathSegments = [
...parentPathSegments,
...toConfigPathSegments(matchingBranchIssue.path),
];
const subMessage =
typeof matchingBranchIssue.message === "string" ? matchingBranchIssue.message : "Invalid input";
return { path: fullPath, message: subMessage };
return withConfigIssuePath(
{ path: formatConfigPath(fullPathSegments), message: subMessage },
fullPathSegments,
);
}
function isObjectSecretRefCandidate(value: unknown): boolean {
@@ -824,7 +840,8 @@ export function collectUnsupportedSecretRefPolicyIssues(raw: unknown): ConfigVal
function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
const record = toIssueRecord(issue);
const pathItem = formatConfigPath(toConfigPathSegments(record?.path));
const pathSegments = toConfigPathSegments(record?.path);
const pathItem = formatConfigPath(pathSegments);
const message = typeof record?.message === "string" ? record.message : "Invalid input";
// Numeric ceiling/floor hints (too_big / too_small with numeric origin).
@@ -843,22 +860,25 @@ function mapZodIssueToConfigIssue(issue: unknown): ConfigValidationIssue {
record.code === "invalid_union" &&
!allowedValuesSummary
) {
const betterIssue = extractBindingsSpecificUnionIssue(record, pathItem);
const betterIssue = extractBindingsSpecificUnionIssue(record, pathSegments);
if (betterIssue) {
return betterIssue;
}
}
if (!allowedValuesSummary) {
return { path: pathItem, message: enrichedMessage };
return withConfigIssuePath({ path: pathItem, message: enrichedMessage }, pathSegments);
}
return {
path: pathItem,
message: appendAllowedValuesHint(enrichedMessage, allowedValuesSummary),
allowedValues: allowedValuesSummary.values,
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
};
return withConfigIssuePath(
{
path: pathItem,
message: appendAllowedValuesHint(enrichedMessage, allowedValuesSummary),
allowedValues: allowedValuesSummary.values,
allowedValuesHiddenCount: allowedValuesSummary.hiddenCount,
},
pathSegments,
);
}
function collectExplicitPluginReferences(raw: unknown): ExplicitPluginReferences {