Gateway/Dashboard: surface config validation issues (#42664)

Merged via squash.

Prepared head SHA: 43f66cdcf0
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com>
Reviewed-by: @huntharo
This commit is contained in:
Harold Hunt
2026-03-11 17:32:41 -04:00
committed by GitHub
parent 4eccea9f7f
commit 20d097ac2f
3 changed files with 53 additions and 3 deletions

View File

@@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai
- Agents/error rendering: ignore stale assistant `errorMessage` fields on successful turns so background/tool-side failures no longer prepend synthetic billing errors over valid replies. (#40616) Thanks @ingyukoh.
- Agents/fallback: recognize Venice `402 Insufficient USD or Diem balance` billing errors so configured model fallbacks trigger instead of surfacing the raw provider error. (#43205) Thanks @Squabble9.
- Dependencies: refresh workspace dependencies except the pinned Carbon package, and harden ACP session-config writes against non-string SDK values so newer ACP clients fail fast instead of tripping type/runtime mismatches.
- Gateway/config errors: surface up to three validation issues in top-level `config.set`, `config.patch`, and `config.apply` error messages while preserving structured issue details. (#42664) Thanks @huntharo.
## 2026.3.8

View File

@@ -10,6 +10,7 @@ import {
validateConfigObjectWithPlugins,
writeConfigFile,
} from "../../config/config.js";
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { applyLegacyMigrations } from "../../config/legacy.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import {
@@ -23,7 +24,7 @@ import {
type ConfigSchemaResponse,
} from "../../config/schema.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ConfigValidationIssue, OpenClawConfig } from "../../config/types.openclaw.js";
import {
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
@@ -54,6 +55,8 @@ import { parseRestartRequestParams } from "./restart-request.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
const MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE = 3;
function requireConfigBaseHash(
params: unknown,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
@@ -158,7 +161,7 @@ function parseValidateConfigFromRawOrRespond(
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);
@@ -167,6 +170,20 @@ function parseValidateConfigFromRawOrRespond(
return { config: validated.config, schema };
}
function summarizeConfigValidationIssues(issues: ReadonlyArray<ConfigValidationIssue>): string {
const trimmed = issues.slice(0, MAX_CONFIG_ISSUES_IN_ERROR_MESSAGE);
const lines = formatConfigIssueLines(trimmed, "", { normalizeRoot: true })
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
return "invalid config";
}
const hiddenCount = Math.max(0, issues.length - lines.length);
return `invalid config: ${lines.join("; ")}${
hiddenCount > 0 ? ` (+${hiddenCount} more issue${hiddenCount === 1 ? "" : "s"})` : ""
}`;
}
function resolveConfigRestartRequest(params: unknown): {
sessionKey: string | undefined;
note: string | undefined;
@@ -398,7 +415,7 @@ export const configHandlers: GatewayRequestHandlers = {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid config", {
errorShape(ErrorCodes.INVALID_REQUEST, summarizeConfigValidationIssues(validated.issues), {
details: { issues: validated.issues },
}),
);

View File

@@ -72,6 +72,38 @@ describe("gateway config methods", () => {
expect(res.payload?.config).toBeTruthy();
});
it("returns config.set validation details in the top-level error message", async () => {
const current = await rpcReq<{
hash?: string;
}>(requireWs(), "config.get", {});
expect(current.ok).toBe(true);
expect(typeof current.payload?.hash).toBe("string");
const res = await rpcReq<{
ok?: boolean;
error?: {
message?: string;
};
}>(requireWs(), "config.set", {
raw: JSON.stringify({ gateway: { bind: 123 } }),
baseHash: current.payload?.hash,
});
const error = res.error as
| {
message?: string;
details?: {
issues?: Array<{ path?: string; message?: string }>;
};
}
| undefined;
expect(res.ok).toBe(false);
expect(error?.message ?? "").toContain("invalid config:");
expect(error?.message ?? "").toContain("gateway.bind");
expect(error?.message ?? "").toContain("allowed:");
expect(error?.details?.issues?.[0]?.path).toBe("gateway.bind");
});
it("returns a path-scoped config schema lookup", async () => {
const res = await rpcReq<{
path: string;