diff --git a/CHANGELOG.md b/CHANGELOG.md index ce64656b14d..d89a11c984c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/troubleshooting.md b/docs/gateway/troubleshooting.md index fd37d4f825b..6eb735132d5 100644 --- a/docs/gateway/troubleshooting.md +++ b/docs/gateway/troubleshooting.md @@ -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 `***`. diff --git a/src/cli/gateway-cli/run.option-collisions.test.ts b/src/cli/gateway-cli/run.option-collisions.test.ts index ca8439c6ad0..e34c7ce2180 100644 --- a/src/cli/gateway-cli/run.option-collisions.test.ts +++ b/src/cli/gateway-cli/run.option-collisions.test.ts @@ -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: : JSON5 parse failed.", ); expect(startGatewayServer).toHaveBeenCalledWith( 19170, diff --git a/src/cli/gateway-cli/run.ts b/src/cli/gateway-cli/run.ts index 9abd816090c..df9a7f5a3dc 100644 --- a/src/cli/gateway-cli/run.ts +++ b/src/cli/gateway-cli/run.ts @@ -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"); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index 09bcf647d20..0a933f03270 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -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", () => ({ diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d4a292e2ceb..f27c6b10c96 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -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); } diff --git a/src/config/io.observe-recovery.test.ts b/src/config/io.observe-recovery.test.ts index 49b22fa498d..21c5890b78a 100644 --- a/src/config/io.observe-recovery.test.ts +++ b/src/config/io.observe-recovery.test.ts @@ -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)); diff --git a/src/config/io.observe-recovery.ts b/src/config/io.observe-recovery.ts index d844197efff..9d595bdc339 100644 --- a/src/config/io.observe-recovery.ts +++ b/src/config/io.observe-recovery.ts @@ -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, { diff --git a/src/config/issue-format.test.ts b/src/config/issue-format.test.ts index fed82f99588..e142b64cd11 100644 --- a/src/config/issue-format.test.ts +++ b/src/config/issue-format.test.ts @@ -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 broken; gateway.auth.password.source: Required; and 1 more"); + }); + it("normalizes issue metadata for machine output", () => { expect( normalizeConfigIssue({ diff --git a/src/config/issue-format.ts b/src/config/issue-format.ts index 599e93986a2..0b81fbba4fa 100644 --- a/src/config/issue-format.ts +++ b/src/config/issue-format.ts @@ -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 ""; @@ -66,3 +70,23 @@ export function formatConfigIssueLines( ): string[] { return issues.map((issue) => formatConfigIssueLine(issue, marker, opts)); } + +export function formatConfigIssueSummary( + issues: ReadonlyArray, + 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`; +} diff --git a/src/gateway/config-recovery-notice.test.ts b/src/gateway/config-recovery-notice.test.ts index 1516aade740..edebba5eaed 100644 --- a/src/gateway/config-recovery-notice.test.ts +++ b/src/gateway/config-recovery-notice.test.ts @@ -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.", ); }); diff --git a/src/gateway/config-recovery-notice.ts b/src/gateway/config-recovery-notice.ts index ad7e45b2059..74ab19a5582 100644 --- a/src/gateway/config-recovery-notice.ts +++ b/src/gateway/config-recovery-notice.ts @@ -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 | 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; }): 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; }): boolean { return enqueueSystemEvent(formatConfigRecoveryNotice(params), { sessionKey: resolveMainSessionKey(params.cfg), diff --git a/src/gateway/config-reload.test.ts b/src/gateway/config-reload.test.ts index 629f469f657..8e4af2faf5b 100644 --- a/src/gateway/config-reload.test.ts +++ b/src/gateway/config-reload.test.ts @@ -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(); diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index a61d8ed0c5b..7b5f7acbaf8 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -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(", "); diff --git a/src/gateway/server-reload-handlers.ts b/src/gateway/server-reload-handlers.ts index 47e067eb626..77d53ae1943 100644 --- a/src/gateway/server-reload-handlers.ts +++ b/src/gateway/server-reload-handlers.ts @@ -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, diff --git a/src/gateway/server-startup-config.recovery.test.ts b/src/gateway/server-startup-config.recovery.test.ts index 15849caaeec..e572f099661 100644 --- a/src/gateway/server-startup-config.recovery.test.ts +++ b/src/gateway/server-startup-config.recovery.test.ts @@ -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'" }], }); }); diff --git a/src/gateway/server-startup-config.ts b/src/gateway/server-startup-config.ts index e7957e71167..00b391fec39 100644 --- a/src/gateway/server-startup-config.ts +++ b/src/gateway/server-startup-config.ts @@ -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, }); } }