From 1ee91d4484e4ee91111f2e5da012dd784932aa33 Mon Sep 17 00:00:00 2001 From: zw-xysk Date: Tue, 21 Jul 2026 12:42:30 +0800 Subject: [PATCH] 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 --- .../update-command-post-core.test.ts | 91 +++++++++++++++++++ .../update-cli/update-command-post-core.ts | 28 +++++- 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/cli/update-cli/update-command-post-core.test.ts diff --git a/src/cli/update-cli/update-command-post-core.test.ts b/src/cli/update-cli/update-command-post-core.test.ts new file mode 100644 index 000000000000..139493f933ad --- /dev/null +++ b/src/cli/update-cli/update-command-post-core.test.ts @@ -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 { + 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`, + ); + }); +}); diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index a73984afef02..5e6b7001a6c7 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -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 {