mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-05-01 06:36:23 +08:00
fix(config): recover critical config clobbers
This commit is contained in:
@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Config/models: merge provider-scoped model allowlist updates and protect model/provider map writes from accidental full replacement, adding `config set --merge` for additive updates and `--replace` for intentional clobbers. Fixes #65920, #68392, and #68653.
|
||||
- Agents/Pi auth: preserve AWS SDK-authenticated Bedrock runs for IMDS and task-role setups, clear stale refresh timers on sentinel fallback, and log unexpected runtime-auth prep failures instead of silently leaving the provider unauthenticated. Thanks @wirjo.
|
||||
- Config/gateway: restore last-known-good config on critical clobber signatures such as missing metadata, missing `gateway.mode`, or sharp size drops, preventing gateway crash loops when a valid backup exists. Fixes #70336.
|
||||
- Config/gateway: recover configs accidentally prefixed with non-JSON output during gateway startup or `openclaw doctor --fix`, preserving the clobbered file as a backup while leaving normal config reads read-only.
|
||||
- Agents/GitHub Copilot: normalize connection-bound Responses item IDs in the Copilot provider wrapper so replayed histories no longer fail after the upstream connection changes. (#69362) Thanks @Menci.
|
||||
- Pi embedded runs: pass real built-in tools into Pi session creation and then narrow active tool names after custom tool registration, so the runner and compaction paths compile cleanly and keep OpenClaw-managed custom tool allowlists without feeding string arrays into `createAgentSession`. Thanks @vincentkoc.
|
||||
|
||||
@@ -100,6 +100,9 @@ The Gateway also keeps a trusted last-known-good copy after a successful startup
|
||||
`openclaw.json` is later changed outside OpenClaw and no longer validates, startup
|
||||
and hot reload preserve the broken file as a timestamped `.clobbered.*` snapshot,
|
||||
restore the last-known-good copy, and log a loud warning with the recovery reason.
|
||||
Startup read recovery also treats sharp size drops, missing config metadata, and a
|
||||
missing `gateway.mode` as critical clobber signatures when the last-known-good
|
||||
copy had those fields.
|
||||
If a status/log line is accidentally prepended before an otherwise valid JSON
|
||||
config, gateway startup and `openclaw doctor --fix` can strip the prefix,
|
||||
preserve the polluted file as `.clobbered.*`, and continue with the recovered
|
||||
|
||||
@@ -303,6 +303,7 @@ Common signatures:
|
||||
- `.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.
|
||||
- `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 `***`.
|
||||
|
||||
Fix options:
|
||||
|
||||
@@ -20,6 +20,7 @@ describe("config observe recovery", () => {
|
||||
const clobberedUpdateChannelConfig = { update: { channel: "beta" } };
|
||||
const clobberedUpdateChannelRaw = `${JSON.stringify(clobberedUpdateChannelConfig, null, 2)}\n`;
|
||||
const recoverableTelegramConfig = {
|
||||
meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" },
|
||||
update: { channel: "beta" },
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } },
|
||||
@@ -49,6 +50,12 @@ describe("config observe recovery", () => {
|
||||
await fsp.copyFile(configPath, `${configPath}.bak`);
|
||||
}
|
||||
|
||||
async function writeConfigRaw(configPath: string, config: Record<string, unknown>) {
|
||||
const raw = `${JSON.stringify(config, null, 2)}\n`;
|
||||
await fsp.writeFile(configPath, raw, "utf-8");
|
||||
return { raw, parsed: config };
|
||||
}
|
||||
|
||||
async function writeClobberedUpdateChannel(configPath: string) {
|
||||
await fsp.writeFile(configPath, clobberedUpdateChannelRaw, "utf-8");
|
||||
return {
|
||||
@@ -82,6 +89,20 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
}
|
||||
|
||||
async function recoverSuspiciousConfigRead(params: {
|
||||
deps: ObserveRecoveryDeps;
|
||||
configPath: string;
|
||||
raw: string;
|
||||
parsed: unknown;
|
||||
}) {
|
||||
return await maybeRecoverSuspiciousConfigRead({
|
||||
deps: params.deps,
|
||||
configPath: params.configPath,
|
||||
raw: params.raw,
|
||||
parsed: params.parsed,
|
||||
});
|
||||
}
|
||||
|
||||
function recoverClobberedUpdateChannelSync(params: {
|
||||
deps: ObserveRecoveryDeps;
|
||||
configPath: string;
|
||||
@@ -142,6 +163,7 @@ describe("config observe recovery", () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath, warn } = makeDeps(home);
|
||||
await seedConfigBackup(configPath, {
|
||||
meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" },
|
||||
update: { channel: "beta" },
|
||||
browser: { enabled: true },
|
||||
gateway: { mode: "local", auth: { mode: "token", token: "secret-token" } },
|
||||
@@ -165,6 +187,97 @@ describe("config observe recovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-restores when metadata disappears from an otherwise valid config", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath } = makeDeps(home);
|
||||
await seedConfigBackup(configPath, recoverableTelegramConfig);
|
||||
const clobbered = await writeConfigRaw(configPath, {
|
||||
update: { channel: "beta" },
|
||||
gateway: { mode: "local" },
|
||||
channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } },
|
||||
});
|
||||
|
||||
const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered });
|
||||
|
||||
expect((recovered.parsed as { meta?: unknown }).meta).toEqual(recoverableTelegramConfig.meta);
|
||||
const observe = await readLastObserveEvent(auditPath);
|
||||
expect(observe?.restoredFromBackup).toBe(true);
|
||||
expect(observe?.suspicious).toEqual(expect.arrayContaining(["missing-meta-vs-last-good"]));
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-restores when gateway mode disappears from the last-good shape", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath } = makeDeps(home);
|
||||
await seedConfigBackup(configPath, recoverableTelegramConfig);
|
||||
const clobbered = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" },
|
||||
update: { channel: "beta" },
|
||||
channels: { telegram: { enabled: true, dmPolicy: "pairing", groupPolicy: "allowlist" } },
|
||||
});
|
||||
|
||||
const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered });
|
||||
|
||||
expect((recovered.parsed as { gateway?: { mode?: string } }).gateway?.mode).toBe("local");
|
||||
const observe = await readLastObserveEvent(auditPath);
|
||||
expect(observe?.restoredFromBackup).toBe(true);
|
||||
expect(observe?.suspicious).toEqual(
|
||||
expect.arrayContaining(["gateway-mode-missing-vs-last-good"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-restores after a large size drop against last-good config", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath } = makeDeps(home);
|
||||
await seedConfigBackup(configPath, {
|
||||
...recoverableTelegramConfig,
|
||||
channels: {
|
||||
telegram: {
|
||||
enabled: true,
|
||||
dmPolicy: "pairing",
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: Array.from({ length: 60 }, (_, index) => `telegram-user-${index}`),
|
||||
},
|
||||
},
|
||||
});
|
||||
const clobbered = await writeConfigRaw(configPath, {
|
||||
meta: { lastTouchedAt: "2026-04-22T00:00:00.000Z" },
|
||||
gateway: { mode: "local" },
|
||||
});
|
||||
|
||||
const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...clobbered });
|
||||
|
||||
expect(
|
||||
(recovered.parsed as { channels?: { telegram?: { allowFrom?: string[] } } }).channels
|
||||
?.telegram?.allowFrom,
|
||||
).toHaveLength(60);
|
||||
const observe = await readLastObserveEvent(auditPath);
|
||||
expect(observe?.restoredFromBackup).toBe(true);
|
||||
expect(observe?.suspicious).toEqual(
|
||||
expect.arrayContaining([expect.stringMatching(/^size-drop-vs-last-good:/)]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not restore noncritical config edits", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath } = makeDeps(home);
|
||||
await seedConfigBackup(configPath, recoverableTelegramConfig);
|
||||
const editedConfig = {
|
||||
...recoverableTelegramConfig,
|
||||
update: { channel: "stable" },
|
||||
};
|
||||
const edited = await writeConfigRaw(configPath, editedConfig);
|
||||
|
||||
const recovered = await recoverSuspiciousConfigRead({ deps, configPath, ...edited });
|
||||
|
||||
expect(recovered.parsed).toEqual(editedConfig);
|
||||
await expect(fsp.readFile(configPath, "utf-8")).resolves.toBe(edited.raw);
|
||||
await expect(fsp.stat(auditPath)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("dedupes repeated suspicious hashes", async () => {
|
||||
await withSuiteHome(async (home) => {
|
||||
const { deps, configPath, auditPath } = makeDeps(home);
|
||||
|
||||
@@ -441,6 +441,15 @@ function resolveSuspiciousSignature(
|
||||
return `${current.hash}:${suspicious.join(",")}`;
|
||||
}
|
||||
|
||||
function isRecoverableConfigReadSuspiciousReason(reason: string): boolean {
|
||||
return (
|
||||
reason === "missing-meta-vs-last-good" ||
|
||||
reason === "gateway-mode-missing-vs-last-good" ||
|
||||
reason === "update-channel-only-root" ||
|
||||
reason.startsWith("size-drop-vs-last-good:")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfigReadRecoveryContext(params: {
|
||||
current: ConfigHealthFingerprint;
|
||||
parsed: unknown;
|
||||
@@ -454,7 +463,7 @@ function resolveConfigReadRecoveryContext(params: {
|
||||
parsed: params.parsed,
|
||||
lastKnownGood: params.backupBaseline,
|
||||
});
|
||||
if (!suspicious.includes("update-channel-only-root")) {
|
||||
if (!suspicious.some(isRecoverableConfigReadSuspiciousReason)) {
|
||||
return null;
|
||||
}
|
||||
const suspiciousSignature = resolveSuspiciousSignature(params.current, suspicious);
|
||||
|
||||
Reference in New Issue
Block a user