diff --git a/CHANGELOG.md b/CHANGELOG.md index 341fa18f4b8..2114e410b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/e2e/lib/upgrade-survivor/assertions.mjs b/scripts/e2e/lib/upgrade-survivor/assertions.mjs index 3e042142ba6..253d5995549 100644 --- a/scripts/e2e/lib/upgrade-survivor/assertions.mjs +++ b/scripts/e2e/lib/upgrade-survivor/assertions.mjs @@ -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", diff --git a/src/cli/update-cli/update-command.test.ts b/src/cli/update-cli/update-command.test.ts index f066b77c5d1..e972a646f68 100644 --- a/src/cli/update-cli/update-command.test.ts +++ b/src/cli/update-cli/update-command.test.ts @@ -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); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 90593779dcc..9cbdb2e16f3 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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["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 { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export async function collectMissingPluginInstallPayloads(params: { + records: Record; + env?: NodeJS.ProcessEnv; +}): Promise { + 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, + ): Promise => { + 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, };