mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:50:43 +00:00
fix: recover missing plugin payloads during update
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user