fix: recover missing plugin payloads during update

This commit is contained in:
Peter Steinberger
2026-05-02 21:53:12 +01:00
parent 47375fd6dc
commit 63a3a0e1ec
4 changed files with 278 additions and 40 deletions

View File

@@ -1,3 +1,5 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
@@ -5,6 +7,7 @@ import {
resolveGatewayInstallEntrypoint,
} from "../../daemon/gateway-entrypoint.js";
import {
collectMissingPluginInstallPayloads,
resolvePostInstallDoctorEnv,
shouldPrepareUpdatedInstallRestart,
resolveUpdatedGatewayRestartPort,
@@ -149,6 +152,76 @@ describe("resolvePostInstallDoctorEnv", () => {
});
});
describe("collectMissingPluginInstallPayloads", () => {
it("reports tracked npm install records whose package payload is absent", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-plugin-payload-"));
const presentDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "present");
const missingDir = path.join(tmpDir, "state", "npm", "node_modules", "@openclaw", "missing");
const noPackageJsonDir = path.join(
tmpDir,
"state",
"npm",
"node_modules",
"@openclaw",
"no-package-json",
);
try {
await fs.mkdir(presentDir, { recursive: true });
await fs.writeFile(path.join(presentDir, "package.json"), '{"name":"@openclaw/present"}\n');
await fs.mkdir(noPackageJsonDir, { recursive: true });
await expect(
collectMissingPluginInstallPayloads({
env: { HOME: tmpDir } as NodeJS.ProcessEnv,
records: {
present: {
source: "npm",
spec: "@openclaw/present@beta",
installPath: presentDir,
},
missing: {
source: "npm",
spec: "@openclaw/missing@beta",
installPath: missingDir,
},
"no-package-json": {
source: "npm",
spec: "@openclaw/no-package-json@beta",
installPath: noPackageJsonDir,
},
"missing-install-path": {
source: "npm",
spec: "@openclaw/missing-install-path@beta",
},
local: {
source: "path",
sourcePath: "/not/checked",
installPath: "/not/checked",
},
},
}),
).resolves.toEqual([
{
pluginId: "missing",
installPath: missingDir,
reason: "missing-package-dir",
},
{
pluginId: "missing-install-path",
reason: "missing-install-path",
},
{
pluginId: "no-package-json",
installPath: noPackageJsonDir,
reason: "missing-package-json",
},
]);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
describe("shouldUseLegacyProcessRestartAfterUpdate", () => {
it("never restarts package updates through the pre-update process", () => {
expect(shouldUseLegacyProcessRestartAfterUpdate({ updateMode: "npm" })).toBe(false);

View File

@@ -17,6 +17,7 @@ import {
import { formatConfigIssueLines } from "../../config/issue-format.js";
import { asResolvedSourceConfig, asRuntimeConfig } from "../../config/materialize.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { PluginInstallRecord } from "../../config/types.plugins.js";
import { GATEWAY_SERVICE_KIND, GATEWAY_SERVICE_MARKER } from "../../daemon/constants.js";
import { resolveGatewayInstallEntrypoint } from "../../daemon/gateway-entrypoint.js";
import { resolveGatewayRestartLogPath } from "../../daemon/restart-logs.js";
@@ -50,12 +51,18 @@ import {
withoutPluginInstallRecords,
withPluginInstallRecords,
} from "../../plugins/installed-plugin-index-records.js";
import { syncPluginsForUpdateChannel, updateNpmInstalledPlugins } from "../../plugins/update.js";
import {
syncPluginsForUpdateChannel,
updateNpmInstalledPlugins,
type PluginUpdateIntegrityDriftParams,
type PluginUpdateOutcome,
} from "../../plugins/update.js";
import { runCommandWithTimeout } from "../../process/exec.js";
import { defaultRuntime } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { stylePromptMessage } from "../../terminal/prompt-style.js";
import { theme } from "../../terminal/theme.js";
import { resolveUserPath } from "../../utils.js";
import { replaceCliName, resolveCliName } from "../cli-name.js";
import { formatCliCommand } from "../command-format.js";
import { installCompletion } from "../completion-runtime.js";
@@ -135,6 +142,12 @@ type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
type MissingPluginInstallPayload = {
pluginId: string;
installPath?: string;
reason: "missing-install-path" | "missing-package-dir" | "missing-package-json";
};
function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
}
@@ -143,6 +156,64 @@ function isPackageManagerUpdateMode(mode: UpdateRunResult["mode"]): mode is "npm
return mode === "npm" || mode === "pnpm" || mode === "bun";
}
function isTrackedPackageInstallRecord(record: PluginInstallRecord): boolean {
return (
record.source === "npm" ||
record.source === "clawhub" ||
record.source === "git" ||
record.source === "marketplace"
);
}
async function pathExists(filePath: string): Promise<boolean> {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
export async function collectMissingPluginInstallPayloads(params: {
records: Record<string, PluginInstallRecord>;
env?: NodeJS.ProcessEnv;
}): Promise<MissingPluginInstallPayload[]> {
const env = params.env ?? process.env;
const missing: MissingPluginInstallPayload[] = [];
for (const [pluginId, record] of Object.entries(params.records).toSorted(([left], [right]) =>
left.localeCompare(right),
)) {
if (!isTrackedPackageInstallRecord(record)) {
continue;
}
const rawInstallPath = normalizeOptionalString(record.installPath);
if (!rawInstallPath) {
missing.push({ pluginId, reason: "missing-install-path" });
continue;
}
const installPath = resolveUserPath(rawInstallPath, env);
if (!(await pathExists(installPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-dir" });
continue;
}
const packageJsonPath = path.join(installPath, "package.json");
if (!(await pathExists(packageJsonPath))) {
missing.push({ pluginId, installPath, reason: "missing-package-json" });
}
}
return missing;
}
function formatMissingPluginPayloadReason(entry: MissingPluginInstallPayload): string {
if (entry.reason === "missing-install-path") {
return "installPath is missing";
}
if (entry.reason === "missing-package-json") {
return `package.json is missing under ${entry.installPath}`;
}
return `package directory is missing: ${entry.installPath}`;
}
export function shouldPrepareUpdatedInstallRestart(params: {
updateMode: UpdateRunResult["mode"];
serviceInstalled: boolean;
@@ -844,41 +915,98 @@ async function updatePluginsAfterCoreUpdate(params: {
});
let pluginConfig = syncResult.config;
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
const pluginUpdateOutcomes: PluginUpdateOutcome[] = [];
let pluginsChanged = syncResult.changed;
let npmPluginsChanged = false;
const onPluginIntegrityDrift = async (drift: PluginUpdateIntegrityDriftParams) => {
integrityDrifts.push({
pluginId: drift.pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
...(drift.resolvedSpec ? { resolvedSpec: drift.resolvedSpec } : {}),
...(drift.resolvedVersion ? { resolvedVersion: drift.resolvedVersion } : {}),
action: "aborted",
});
if (!params.opts.json) {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}` +
"\nPlugin update aborted. Reinstall the plugin only if you trust the new artifact.",
),
);
}
return false;
};
const repairMissingPayloads = async (
records: Record<string, PluginInstallRecord>,
): Promise<readonly string[]> => {
const missing = await collectMissingPluginInstallPayloads({ records });
if (missing.length === 0) {
return [];
}
const missingIds = missing.map((entry) => entry.pluginId);
if (!params.opts.json) {
defaultRuntime.log(
theme.warn(
`Recovering missing plugin install payloads: ${missing
.map((entry) => `${entry.pluginId} (${formatMissingPluginPayloadReason(entry)})`)
.join(", ")}.`,
),
);
}
const repairResult = await updateNpmInstalledPlugins({
config: pluginConfig,
pluginIds: missingIds,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
logger: pluginLogger,
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = repairResult.config;
pluginsChanged ||= repairResult.changed;
npmPluginsChanged ||= repairResult.changed;
pluginUpdateOutcomes.push(...repairResult.outcomes);
return missingIds;
};
const repairedMissingPayloadIds = await repairMissingPayloads(
pluginConfig.plugins?.installs ?? {},
);
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
timeoutMs: params.timeoutMs,
updateChannel: params.channel,
skipIds: new Set(syncResult.summary.switchedToNpm),
skipIds: new Set([...syncResult.summary.switchedToNpm, ...repairedMissingPayloadIds]),
skipDisabledPlugins: true,
logger: pluginLogger,
onIntegrityDrift: async (drift) => {
integrityDrifts.push({
pluginId: drift.pluginId,
spec: drift.spec,
expectedIntegrity: drift.expectedIntegrity,
actualIntegrity: drift.actualIntegrity,
...(drift.resolvedSpec ? { resolvedSpec: drift.resolvedSpec } : {}),
...(drift.resolvedVersion ? { resolvedVersion: drift.resolvedVersion } : {}),
action: "aborted",
});
if (!params.opts.json) {
const specLabel = drift.resolvedSpec ?? drift.spec;
defaultRuntime.log(
theme.warn(
`Integrity drift detected for "${drift.pluginId}" (${specLabel})` +
`\nExpected: ${drift.expectedIntegrity}` +
`\nActual: ${drift.actualIntegrity}` +
"\nPlugin update aborted. Reinstall the plugin only if you trust the new artifact.",
),
);
}
return false;
},
onIntegrityDrift: onPluginIntegrityDrift,
});
pluginConfig = npmResult.config;
pluginsChanged ||= npmResult.changed;
npmPluginsChanged ||= npmResult.changed;
pluginUpdateOutcomes.push(...npmResult.outcomes);
if (syncResult.changed || npmResult.changed) {
const remainingMissingPayloads = await collectMissingPluginInstallPayloads({
records: pluginConfig.plugins?.installs ?? {},
});
pluginUpdateOutcomes.push(
...remainingMissingPayloads.map(
(entry): PluginUpdateOutcome => ({
pluginId: entry.pluginId,
status: "error",
message: `Plugin install payload missing after update: ${formatMissingPluginPayloadReason(entry)}.`,
}),
),
);
if (pluginsChanged) {
const nextInstallRecords = pluginConfig.plugins?.installs ?? {};
const nextConfig = withoutPluginInstallRecords(pluginConfig);
await commitPluginInstallRecordsWithConfig({
@@ -900,10 +1028,10 @@ async function updatePluginsAfterCoreUpdate(params: {
return {
status:
syncResult.summary.errors.length > 0 ||
npmResult.outcomes.some((outcome) => outcome.status === "error")
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: syncResult.changed || npmResult.changed,
changed: pluginsChanged,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
@@ -912,8 +1040,8 @@ async function updatePluginsAfterCoreUpdate(params: {
errors: syncResult.summary.errors,
},
npm: {
changed: npmResult.changed,
outcomes: npmResult.outcomes,
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};
@@ -945,12 +1073,12 @@ async function updatePluginsAfterCoreUpdate(params: {
defaultRuntime.log(theme.error(error));
}
const updated = npmResult.outcomes.filter((entry) => entry.status === "updated").length;
const unchanged = npmResult.outcomes.filter((entry) => entry.status === "unchanged").length;
const failed = npmResult.outcomes.filter((entry) => entry.status === "error").length;
const skipped = npmResult.outcomes.filter((entry) => entry.status === "skipped").length;
const updated = pluginUpdateOutcomes.filter((entry) => entry.status === "updated").length;
const unchanged = pluginUpdateOutcomes.filter((entry) => entry.status === "unchanged").length;
const failed = pluginUpdateOutcomes.filter((entry) => entry.status === "error").length;
const skipped = pluginUpdateOutcomes.filter((entry) => entry.status === "skipped").length;
if (npmResult.outcomes.length === 0) {
if (pluginUpdateOutcomes.length === 0) {
defaultRuntime.log(theme.muted("No plugin updates needed."));
} else {
const parts = [`${updated} updated`, `${unchanged} unchanged`];
@@ -963,7 +1091,7 @@ async function updatePluginsAfterCoreUpdate(params: {
defaultRuntime.log(theme.muted(`npm plugins: ${parts.join(", ")}.`));
}
for (const outcome of npmResult.outcomes) {
for (const outcome of pluginUpdateOutcomes) {
if (outcome.status !== "error") {
continue;
}
@@ -973,10 +1101,10 @@ async function updatePluginsAfterCoreUpdate(params: {
return {
status:
syncResult.summary.errors.length > 0 ||
npmResult.outcomes.some((outcome) => outcome.status === "error")
pluginUpdateOutcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: syncResult.changed || npmResult.changed,
changed: pluginsChanged,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
@@ -985,8 +1113,8 @@ async function updatePluginsAfterCoreUpdate(params: {
errors: syncResult.summary.errors,
},
npm: {
changed: npmResult.changed,
outcomes: npmResult.outcomes,
changed: npmPluginsChanged,
outcomes: pluginUpdateOutcomes,
},
integrityDrifts,
};