mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 `***`.
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(", ");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'" }],
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user