fix(cli): silent drop of malformed post-core install-records JSON (#109989)

* fix(cli): fail closed on malformed post-core install-records JSON

Missing handoff files stay optional. Corrupt JSON previously returned
undefined and dropped parent recovery context during update resume.

* test(cli): cover post-core install-records missing vs malformed JSON

Includes live temp-file proof that corrupt handoff is rejected.

* fix(cli): direct corrupt post-core handoffs to doctor

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
zw-xysk
2026-07-21 12:42:30 +08:00
committed by GitHub
parent a0e21c35aa
commit 1ee91d4484
2 changed files with 115 additions and 4 deletions
@@ -0,0 +1,91 @@
// Post-core install-records handoff reader: missing vs malformed JSON.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { readPostCorePluginInstallRecordsFile } from "./update-command-post-core.js";
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
}),
);
});
async function withTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-post-core-records-"));
tempDirs.push(dir);
return dir;
}
describe("readPostCorePluginInstallRecordsFile", () => {
it("returns undefined when the path is omitted", async () => {
await expect(readPostCorePluginInstallRecordsFile(undefined)).resolves.toBeUndefined();
});
it("returns undefined when the handoff file is missing", async () => {
const dir = await withTempDir();
const missing = path.join(dir, "missing-plugin-install-records.json");
await expect(readPostCorePluginInstallRecordsFile(missing)).resolves.toBeUndefined();
});
it("loads a valid install-records handoff", async () => {
const dir = await withTempDir();
const filePath = path.join(dir, "plugin-install-records.json");
await fs.writeFile(
filePath,
`${JSON.stringify({
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
installPath: "/tmp/demo-plugin",
},
})}\n`,
"utf-8",
);
await expect(readPostCorePluginInstallRecordsFile(filePath)).resolves.toEqual({
demo: {
source: "npm",
spec: "@openclaw/demo@1.0.0",
installPath: "/tmp/demo-plugin",
},
});
});
it("fails closed on malformed handoff JSON with a path-labelled error", async () => {
const dir = await withTempDir();
const filePath = path.join(dir, "plugin-install-records.json");
await fs.writeFile(filePath, "{invalid json", "utf-8");
await expect(readPostCorePluginInstallRecordsFile(filePath)).rejects.toThrow(
`Malformed JSON in plugin install records file: ${filePath}`,
);
await expect(readPostCorePluginInstallRecordsFile(filePath)).rejects.toThrow(
"Run openclaw doctor to inspect and repair plugin installation state.",
);
});
it("live FS: corrupt handoff is not silently dropped as empty records", async () => {
// L3: real temp file + real fs.readFile/JSON.parse (no stubs).
const dir = await withTempDir();
const filePath = path.join(dir, "plugin-install-records.json");
await fs.writeFile(filePath, '[{"not":"a-record-map"', "utf-8");
let threw = false;
try {
await readPostCorePluginInstallRecordsFile(filePath);
} catch (err) {
threw = true;
expect(String(err)).toContain(`Malformed JSON in plugin install records file: ${filePath}`);
}
expect(threw).toBe(true);
console.info(
`[post-core install-records live proof] path=${filePath} outcome=malformed-json-rejected`,
);
});
});
+24 -4
View File
@@ -18,6 +18,7 @@ import {
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { hasErrnoCode } from "../../infra/errors.js";
import { readJsonIfExists, writeJson } from "../../infra/json-files.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
import {
@@ -326,12 +327,31 @@ export async function readPostCorePluginInstallRecordsFile(
if (!filePath) {
return undefined;
}
// Missing handoff is optional (parent may omit the path). Corrupt / unreadable
// handoff must fail closed: silent undefined previously dropped parent install
// recovery context when the post-doctor index was still empty.
let raw: string;
try {
const parsed = JSON.parse(await fs.readFile(filePath, "utf-8")) as unknown;
return normalizePluginInstallRecordMap(parsed);
} catch {
return undefined;
raw = await fs.readFile(filePath, "utf-8");
} catch (err) {
if (hasErrnoCode(err, "ENOENT")) {
return undefined;
}
throw new Error(
`Unable to read plugin install records file: ${filePath}. Run openclaw doctor to inspect and repair plugin installation state.`,
{ cause: err },
);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(
`Malformed JSON in plugin install records file: ${filePath}. Run openclaw doctor to inspect and repair plugin installation state.`,
{ cause: err },
);
}
return normalizePluginInstallRecordMap(parsed);
}
async function execFileStdout(file: string, args: string[]): Promise<string | undefined> {