fix(gateway): show config recovery validation details (#75081)

* fix(gateway): show config recovery validation details

* fix(cli): let gateway recovery run before proxy bootstrap
This commit is contained in:
Alex Knight
2026-05-01 07:14:33 +10:00
committed by GitHub
parent 4429ee7d2e
commit aa9db998f7
17 changed files with 115 additions and 15 deletions

View File

@@ -39,6 +39,7 @@ Docs: https://docs.openclaw.ai
- Infra/tmp: tolerate concurrent temp-dir permission repairs by rechecking directories that another process already tightened, so parallel ACP subprocess startup no longer throws `Unsafe fallback OpenClaw temp dir`. Fixes #66867. Thanks @Kane808-AI and @jarvisz8.
- Agents/compaction: add an opt-in `agents.defaults.compaction.midTurnPrecheck` mid-turn precheck that detects tool-loop context pressure and triggers compaction before the next tool call instead of waiting for end-of-turn. (#73499) Thanks @marchpure and @haoxingjun.
- Gateway/approvals: let loopback token/password-backed native approval clients resolve exec approvals without attaching stale paired Gateway identities, while remote and unauthenticated approval clients keep normal device identity behavior. (#74472)
- Gateway/config: include rejected validation paths in foreground and service last-known-good recovery logs plus main-agent notices, so unsupported direct edits explain which key caused restore instead of looking like silent reversion. Fixes #75060. Thanks @amknight.
## 2026.4.29

View File

@@ -341,6 +341,7 @@ Look for:
- `.clobbered.*` exists → an external direct edit or startup read was restored.
- `.rejected.*` exists → an OpenClaw-owned config write failed schema or clobber checks before commit.
- `Config write rejected:` → the write tried to drop required shape, shrink the file sharply, or persist invalid config.
- `Rejected validation details:` → the recovery log or main-agent notice includes the schema path that caused the restore, such as `agents.defaults.execution` or `gateway.auth.password.source`.
- `missing-meta-vs-last-good`, `gateway-mode-missing-vs-last-good`, or `size-drop-vs-last-good:*` → startup treated the current file as clobbered because it lost fields or size compared with the last-known-good backup.
- `Config last-known-good promotion skipped` → the candidate contained redacted secret placeholders such as `***`.

View File

@@ -408,7 +408,7 @@ describe("gateway run option collisions", () => {
},
});
expect(gatewayLogMessages).toContain(
"gateway: restored invalid effective config from last-known-good backup: /tmp/openclaw-test-missing-config.json",
"gateway: restored invalid effective config from last-known-good backup: /tmp/openclaw-test-missing-config.json; Rejected validation details: <root>: JSON5 parse failed.",
);
expect(startGatewayServer).toHaveBeenCalledWith(
19170,

View File

@@ -8,6 +8,7 @@ import type {
GatewayBindMode,
GatewayTailscaleMode,
} from "../../config/config.js";
import { formatConfigIssueSummary } from "../../config/issue-format.js";
import { CONFIG_PATH, resolveGatewayPort, resolveStateDir } from "../../config/paths.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasConfiguredSecretInput } from "../../config/types.secrets.js";
@@ -290,8 +291,12 @@ async function readGatewayStartupConfig(params: {
}),
);
if (recovered) {
const issueSummary = formatConfigIssueSummary([
...invalidSnapshot.issues,
...invalidSnapshot.legacyIssues,
]);
gatewayLog.warn(
`gateway: restored invalid effective config from last-known-good backup: ${invalidSnapshot.path}`,
`gateway: restored invalid effective config from last-known-good backup: ${invalidSnapshot.path}${issueSummary ? `; Rejected validation details: ${issueSummary}.` : ""}`,
);
try {
const { writeRestartSentinel } = await import("../../infra/restart-sentinel.js");

View File

@@ -177,7 +177,7 @@ vi.mock("./progress.js", () => ({
}));
vi.mock("../config/io.js", () => ({
getRuntimeConfig: loadConfigMock,
readBestEffortConfig: loadConfigMock,
}));
vi.mock("../infra/net/proxy/proxy-lifecycle.js", () => ({

View File

@@ -342,11 +342,11 @@ export async function runCli(argv: string[] = process.argv) {
handle?.kill("SIGTERM");
};
if (shouldStartProxyForCli(normalizedArgv)) {
const [{ getRuntimeConfig }, { startProxy }] = await Promise.all([
const [{ readBestEffortConfig }, { startProxy }] = await Promise.all([
import("../config/io.js"),
import("../infra/net/proxy/proxy-lifecycle.js"),
]);
const config = getRuntimeConfig();
const config = await readBestEffortConfig();
proxyHandle = await startProxy(config?.proxy ?? undefined);
}

View File

@@ -341,6 +341,9 @@ describe("config observe recovery", () => {
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Config auto-restored from last-known-good:"),
);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Rejected validation details: gateway.mode: Expected string."),
);
const observe = await readLastObserveEvent(auditPath);
expect(observe?.restoredFromBackup).toBe(true);
expect(observe?.restoredBackupPath).toBe(resolveLastKnownGoodConfigPath(configPath));

View File

@@ -6,6 +6,7 @@ import {
appendConfigAuditRecordSync,
type ConfigObserveAuditRecord,
} from "./io.audit.js";
import { formatConfigIssueSummary } from "./issue-format.js";
import { resolveStateDir } from "./paths.js";
import {
isPluginLocalInvalidConfigSnapshot,
@@ -1068,8 +1069,9 @@ export async function recoverConfigFromLastKnownGood(params: {
});
await deps.fs.promises.copyFile(lastGoodPath, snapshot.path);
await deps.fs.promises.chmod?.(snapshot.path, 0o600).catch(() => {});
const issueSummary = formatConfigIssueSummary([...snapshot.issues, ...snapshot.legacyIssues]);
deps.logger.warn(
`Config auto-restored from last-known-good: ${snapshot.path} (${params.reason})`,
`Config auto-restored from last-known-good: ${snapshot.path} (${params.reason})${issueSummary ? `; Rejected validation details: ${issueSummary}.` : ""}`,
);
await appendConfigAuditRecord(
createConfigObserveAuditAppendParams(deps, {

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
formatConfigIssueLine,
formatConfigIssueLines,
formatConfigIssueSummary,
normalizeConfigIssue,
normalizeConfigIssuePath,
normalizeConfigIssues,
@@ -47,6 +48,20 @@ describe("config issue format", () => {
).toBe("- gateway.\\nbind: bad\\r\\n\\tvalue");
});
it("formats concise issue summaries", () => {
expect(formatConfigIssueSummary([])).toBeNull();
expect(
formatConfigIssueSummary(
[
{ path: "", message: "root broken" },
{ path: "gateway.auth.password.source", message: "Required" },
{ path: "agents.defaults.execution", message: "Unrecognized key" },
],
{ maxIssues: 2 },
),
).toBe("<root>: root broken; gateway.auth.password.source: Required; and 1 more");
});
it("normalizes issue metadata for machine output", () => {
expect(
normalizeConfigIssue({

View File

@@ -1,7 +1,7 @@
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import type { ConfigValidationIssue } from "./types.js";
type ConfigIssueLineInput = {
export type ConfigIssueLineInput = {
path?: string | null;
message: string;
};
@@ -10,6 +10,10 @@ type ConfigIssueFormatOptions = {
normalizeRoot?: boolean;
};
type ConfigIssueSummaryOptions = ConfigIssueFormatOptions & {
maxIssues?: number;
};
export function normalizeConfigIssuePath(path: string | null | undefined): string {
if (typeof path !== "string") {
return "<root>";
@@ -66,3 +70,23 @@ export function formatConfigIssueLines(
): string[] {
return issues.map((issue) => formatConfigIssueLine(issue, marker, opts));
}
export function formatConfigIssueSummary(
issues: ReadonlyArray<ConfigIssueLineInput>,
opts: ConfigIssueSummaryOptions = {},
): string | null {
if (issues.length === 0) {
return null;
}
const maxIssueCandidate = Math.floor(opts.maxIssues ?? 5);
const maxIssues = Number.isFinite(maxIssueCandidate) ? Math.max(1, maxIssueCandidate) : 5;
const visibleIssues = issues.slice(0, maxIssues);
const lines = formatConfigIssueLines(visibleIssues, "", {
normalizeRoot: opts.normalizeRoot ?? true,
});
const hiddenIssueCount = issues.length - visibleIssues.length;
if (hiddenIssueCount <= 0) {
return lines.join("; ");
}
return `${lines.join("; ")}; and ${hiddenIssueCount} more`;
}

View File

@@ -26,6 +26,22 @@ describe("config recovery notice", () => {
);
});
it("includes rejected validation details when available", () => {
expect(
formatConfigRecoveryNotice({
phase: "startup",
reason: "startup-invalid-config",
configPath: "/home/test/.openclaw/openclaw.json",
issues: [
{ path: "agents.defaults.execution", message: "Unrecognized key: execution" },
{ path: "gateway.auth.password.source", message: "Required" },
],
}),
).toContain(
"Rejected validation details: agents.defaults.execution: Unrecognized key: execution; gateway.auth.password.source: Required.",
);
});
it("queues the notice for the main agent session", () => {
expect(
enqueueConfigRecoveryNotice({
@@ -33,11 +49,14 @@ describe("config recovery notice", () => {
phase: "reload",
reason: "reload-invalid-config",
configPath: "/home/test/.openclaw/openclaw.json",
issues: [{ path: "gateway.mode", message: "Expected string" }],
}),
).toBe(true);
expect(peekSystemEvents("agent:main:main")).toHaveLength(1);
expect(drainSystemEvents("agent:main:main")[0]).toContain(
const notice = drainSystemEvents("agent:main:main")[0];
expect(notice).toContain("gateway.mode: Expected string");
expect(notice).toContain(
"Do not write openclaw.json again unless you validate the full config first.",
);
});

View File

@@ -1,21 +1,33 @@
import path from "node:path";
import { formatConfigIssueSummary, type ConfigIssueLineInput } from "../config/issue-format.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
export type ConfigRecoveryNoticePhase = "startup" | "reload";
export function formatConfigRecoveryIssueSentence(
issues: ReadonlyArray<ConfigIssueLineInput> | undefined,
): string | null {
const summary = formatConfigIssueSummary(issues ?? []);
return summary ? `Rejected validation details: ${summary}.` : null;
}
export function formatConfigRecoveryNotice(params: {
phase: ConfigRecoveryNoticePhase;
reason: string;
configPath: string;
issues?: ReadonlyArray<ConfigIssueLineInput>;
}): string {
const configName = path.basename(params.configPath) || "openclaw.json";
return [
`Config recovery warning: OpenClaw restored ${configName} from the last-known-good backup during ${params.phase} (${params.reason}).`,
"The rejected config was invalid and was preserved as a timestamped .clobbered.* file.",
formatConfigRecoveryIssueSentence(params.issues),
`Do not write ${configName} again unless you validate the full config first.`,
].join(" ");
]
.filter((line): line is string => Boolean(line))
.join(" ");
}
export function enqueueConfigRecoveryNotice(params: {
@@ -23,6 +35,7 @@ export function enqueueConfigRecoveryNotice(params: {
phase: ConfigRecoveryNoticePhase;
reason: string;
configPath: string;
issues?: ReadonlyArray<ConfigIssueLineInput>;
}): boolean {
return enqueueSystemEvent(formatConfigRecoveryNotice(params), {
sessionKey: resolveMainSessionKey(params.cfg),

View File

@@ -748,7 +748,7 @@ describe("startGatewayConfigReloader", () => {
"valid-config",
);
expect(log.warn).toHaveBeenCalledWith(
"config reload restored last-known-good config after invalid-config",
"config reload restored last-known-good config after invalid-config; Rejected validation details: gateway.mode: Expected string.",
);
await reloader.stop();

View File

@@ -2,7 +2,7 @@ import { isDeepStrictEqual } from "node:util";
import chokidar from "chokidar";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh-state.js";
import type { ConfigWriteNotification } from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { formatConfigIssueLines, formatConfigIssueSummary } from "../config/issue-format.js";
import { materializeRuntimeConfig } from "../config/materialize.js";
import {
isPluginLocalInvalidConfigSnapshot,
@@ -271,7 +271,10 @@ export function startGatewayConfigReloader(opts: {
if (!recovered) {
return null;
}
opts.log.warn(`config reload restored last-known-good config after ${reason}`);
const issueSummary = formatConfigIssueSummary([...snapshot.issues, ...snapshot.legacyIssues]);
opts.log.warn(
`config reload restored last-known-good config after ${reason}${issueSummary ? `; Rejected validation details: ${issueSummary}.` : ""}`,
);
const nextSnapshot = await opts.readSnapshot();
if (!nextSnapshot.valid) {
const issues = formatConfigIssueLines(nextSnapshot.issues, "").join(", ");

View File

@@ -447,6 +447,7 @@ export function startManagedGatewayConfigReloader(params: ManagedGatewayConfigRe
phase: "reload",
reason: `reload-${reason}`,
configPath: snapshot.path,
issues: [...snapshot.issues, ...snapshot.legacyIssues],
});
},
subscribeToWrites: params.subscribeToWrites,

View File

@@ -389,13 +389,14 @@ describe("gateway startup config recovery", () => {
reason: "startup-invalid-config",
});
expect(log.warn).toHaveBeenCalledWith(
`gateway: invalid config was restored from last-known-good backup: ${configPath}`,
`gateway: invalid config was restored from last-known-good backup: ${configPath}; Rejected validation details: gateway.mode: Expected 'local' or 'remote'.`,
);
expect(recoveryNotice.enqueueConfigRecoveryNotice).toHaveBeenCalledWith({
cfg: recoveredSnapshot.config,
phase: "startup",
reason: "startup-invalid-config",
configPath,
issues: [{ path: "gateway.mode", message: "Expected 'local' or 'remote'" }],
});
});

View File

@@ -4,7 +4,7 @@ import {
recoverConfigFromLastKnownGood,
recoverConfigFromJsonRootSuffix,
} from "../config/io.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { formatConfigIssueLines, formatConfigIssueSummary } from "../config/issue-format.js";
import { asResolvedSourceConfig, materializeRuntimeConfig } from "../config/materialize.js";
import { replaceConfigFile } from "../config/mutate.js";
import { isNixMode } from "../config/paths.js";
@@ -157,6 +157,15 @@ function resolveGatewayStartupConfigWithoutInvalidModelProviders(params: {
};
}
function collectConfigSnapshotIssueDetails(snapshot: ConfigFileSnapshot) {
return [...snapshot.issues, ...snapshot.legacyIssues];
}
function formatConfigRecoveryLogIssueSuffix(snapshot: ConfigFileSnapshot): string {
const summary = formatConfigIssueSummary(collectConfigSnapshotIssueDetails(snapshot));
return summary ? `; Rejected validation details: ${summary}.` : "";
}
function resolveGatewayStartupConfigWithoutInvalidPluginEntries(params: {
snapshot: ConfigFileSnapshot;
log: GatewayStartupLog;
@@ -229,6 +238,8 @@ export async function loadGatewayStartupConfigSnapshot(params: {
}
}
if (!configSnapshot.valid) {
const rejectedSnapshot = configSnapshot;
const rejectedConfigIssues = collectConfigSnapshotIssueDetails(rejectedSnapshot);
const canRecoverFromLastKnownGood = shouldAttemptLastKnownGoodRecovery(configSnapshot);
const recovered = canRecoverFromLastKnownGood
? await recoverConfigFromLastKnownGood({
@@ -244,7 +255,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
if (recovered) {
wroteConfig = true;
params.log.warn(
`gateway: invalid config was restored from last-known-good backup: ${configSnapshot.path}`,
`gateway: invalid config was restored from last-known-good backup: ${rejectedSnapshot.path}${formatConfigRecoveryLogIssueSuffix(rejectedSnapshot)}`,
);
snapshotRead = await measure("config.snapshot.recovery-read", () =>
readConfigFileSnapshotWithPluginMetadata({ measure }),
@@ -257,6 +268,7 @@ export async function loadGatewayStartupConfigSnapshot(params: {
phase: "startup",
reason: "startup-invalid-config",
configPath: configSnapshot.path,
issues: rejectedConfigIssues,
});
}
}