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

@@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugins/externalization: add official npm-first catalogs for externalized channel, provider, and generic plugins, keep unpublished ACPX/Google Chat/LINE bundled, and make missing-plugin repair honor npm-first metadata while ClawHub pack files roll out. Thanks @vincentkoc.
- Plugins/update: detect tracked plugin install records whose package directories disappeared during `openclaw update`, reinstall them before normal plugin updates, and fail the update if any install record still points at missing disk payloads.
- CLI/infer: reject local `codex/*` one-shot model probes before simple-completion dispatch and point operators at the Codex app-server runtime path instead of ending with an empty-output error.
- Agents/sessions: preserve terminal lifecycle state when final run metadata persists from a stale in-memory snapshot, preventing `main` sessions from staying stuck as running after completed or timed-out turns.
- Gateway/CLI: make `openclaw gateway start` repair stale managed service definitions that point at old OpenClaw versions, missing binaries, or temporary installer paths before starting.

View File

@@ -31,6 +31,24 @@ function readJson(file) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
function resolveHomePath(value) {
if (typeof value !== "string" || value.length === 0) {
return "";
}
if (value === "~") {
return process.env.HOME || value;
}
if (value.startsWith("~/")) {
return path.join(process.env.HOME || "", value.slice(2));
}
return value;
}
function isPathInside(parent, child) {
const relative = path.relative(parent, child);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function write(file, contents) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, contents);
@@ -375,12 +393,30 @@ function assertConfiguredPluginInstalls() {
matrix.source === "clawhub" || matrix.source === "npm",
`configured external matrix plugin installed from unexpected source: ${matrix.source}`,
);
const installPath = resolveHomePath(matrix.installPath);
assert(
installPath,
`configured external matrix plugin installPath missing: ${JSON.stringify(matrix)}`,
);
assert(
fs.existsSync(installPath),
`configured external matrix plugin installPath missing on disk: ${installPath}`,
);
assert(
fs.existsSync(path.join(installPath, "package.json")),
`configured external matrix plugin package.json missing: ${installPath}`,
);
if (matrix.source === "clawhub") {
assert(
String(matrix.spec ?? "").startsWith("clawhub:@openclaw/matrix"),
"configured external matrix plugin ClawHub spec changed",
);
} else {
const npmRoot = path.join(requireEnv("OPENCLAW_STATE_DIR"), "npm", "node_modules");
assert(
isPathInside(npmRoot, installPath),
`configured external matrix npm install path outside managed npm root: ${installPath}`,
);
assert(
String(matrix.spec ?? matrix.resolvedSpec ?? "").startsWith("@openclaw/matrix"),
"configured external matrix plugin npm spec changed",

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,
};