fix: fail closed on plugin integrity drift

This commit is contained in:
Peter Steinberger
2026-04-22 14:57:05 +01:00
parent dc2c3a4920
commit 0f4ec84a2c
10 changed files with 497 additions and 38 deletions

View File

@@ -1,4 +1,6 @@
import { spawn } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { confirm, isCancel } from "@clack/prompts";
import {
@@ -80,6 +82,7 @@ const CLI_NAME = resolveCliName();
const SERVICE_REFRESH_TIMEOUT_MS = 60_000;
const POST_CORE_UPDATE_ENV = "OPENCLAW_UPDATE_POST_CORE";
const POST_CORE_UPDATE_CHANNEL_ENV = "OPENCLAW_UPDATE_POST_CORE_CHANNEL";
const POST_CORE_UPDATE_RESULT_PATH_ENV = "OPENCLAW_UPDATE_POST_CORE_RESULT_PATH";
const SERVICE_REFRESH_PATH_ENV_KEYS = [
"OPENCLAW_HOME",
"OPENCLAW_STATE_DIR",
@@ -109,6 +112,10 @@ const UPDATE_QUIPS = [
"Version bump! Same chaos energy, fewer crashes (probably).",
];
type PostCorePluginUpdateResult = NonNullable<
NonNullable<UpdateRunResult["postUpdate"]>["plugins"]
>;
function pickUpdateQuip(): string {
return UPDATE_QUIPS[Math.floor(Math.random() * UPDATE_QUIPS.length)] ?? "Update complete.";
}
@@ -531,12 +538,28 @@ async function updatePluginsAfterCoreUpdate(params: {
channel: "stable" | "beta" | "dev";
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
}): Promise<void> {
}): Promise<PostCorePluginUpdateResult> {
if (!params.configSnapshot.valid) {
if (!params.opts.json) {
defaultRuntime.log(theme.warn("Skipping plugin updates: config is invalid."));
}
return;
return {
status: "skipped",
reason: "invalid-config",
changed: false,
sync: {
changed: false,
switchedToBundled: [],
switchedToNpm: [],
warnings: [],
errors: [],
},
npm: {
changed: false,
outcomes: [],
},
integrityDrifts: [],
};
}
const pluginLogger = params.opts.json
@@ -559,11 +582,35 @@ async function updatePluginsAfterCoreUpdate(params: {
logger: pluginLogger,
});
let pluginConfig = syncResult.config;
const integrityDrifts: PostCorePluginUpdateResult["integrityDrifts"] = [];
const npmResult = await updateNpmInstalledPlugins({
config: pluginConfig,
skipIds: new Set(syncResult.summary.switchedToNpm),
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;
},
});
pluginConfig = npmResult.config;
@@ -575,7 +622,26 @@ async function updatePluginsAfterCoreUpdate(params: {
}
if (params.opts.json) {
return;
return {
status:
syncResult.summary.errors.length > 0 ||
npmResult.outcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: syncResult.changed || npmResult.changed,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmResult.changed,
outcomes: npmResult.outcomes,
},
integrityDrifts,
};
}
const summarizeList = (list: string[]) => {
@@ -628,6 +694,27 @@ async function updatePluginsAfterCoreUpdate(params: {
}
defaultRuntime.log(theme.error(outcome.message));
}
return {
status:
syncResult.summary.errors.length > 0 ||
npmResult.outcomes.some((outcome) => outcome.status === "error")
? "error"
: "ok",
changed: syncResult.changed || npmResult.changed,
sync: {
changed: syncResult.changed,
switchedToBundled: syncResult.summary.switchedToBundled,
switchedToNpm: syncResult.summary.switchedToNpm,
warnings: syncResult.summary.warnings,
errors: syncResult.summary.errors,
},
npm: {
changed: npmResult.changed,
outcomes: npmResult.outcomes,
},
integrityDrifts,
};
}
async function maybeRestartService(params: {
@@ -767,8 +854,8 @@ async function runPostCorePluginUpdate(params: {
channel: "stable" | "beta" | "dev";
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
opts: UpdateCommandOptions;
}): Promise<void> {
await updatePluginsAfterCoreUpdate({
}): Promise<PostCorePluginUpdateResult> {
return await updatePluginsAfterCoreUpdate({
root: params.root,
channel: params.channel,
configSnapshot: params.configSnapshot,
@@ -776,14 +863,43 @@ async function runPostCorePluginUpdate(params: {
});
}
async function writePostCorePluginUpdateResultFile(
filePath: string | undefined,
result: PostCorePluginUpdateResult,
): Promise<void> {
if (!filePath) {
return;
}
await fs.writeFile(filePath, `${JSON.stringify(result)}\n`, "utf-8");
}
async function readPostCorePluginUpdateResultFile(
filePath: string,
): Promise<PostCorePluginUpdateResult | undefined> {
try {
const raw = await fs.readFile(filePath, "utf-8");
const parsed = JSON.parse(raw) as PostCorePluginUpdateResult;
if (
parsed &&
typeof parsed === "object" &&
(parsed.status === "ok" || parsed.status === "skipped" || parsed.status === "error")
) {
return parsed;
}
} catch {
return undefined;
}
return undefined;
}
async function continuePostCoreUpdateInFreshProcess(params: {
root: string;
channel: "stable" | "beta" | "dev";
opts: UpdateCommandOptions;
}): Promise<boolean> {
}): Promise<{ resumed: boolean; pluginUpdate?: PostCorePluginUpdateResult }> {
const entryPath = path.join(params.root, "dist", "entry.js");
if (!(await pathExists(entryPath))) {
return false;
return { resumed: false };
}
const argv = [entryPath, "update"];
@@ -796,32 +912,47 @@ async function continuePostCoreUpdateInFreshProcess(params: {
if (params.opts.yes) {
argv.push("--yes");
}
const resultDir =
params.opts.json === true
? await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-update-post-core-"))
: null;
const resultPath = resultDir ? path.join(resultDir, "plugins.json") : null;
const child = spawn(resolveNodeRunner(), argv, {
stdio: "inherit",
env: {
...process.env,
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
},
});
const exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
if (signal) {
reject(new Error(`post-update process terminated by signal ${signal}`));
return;
}
resolve(code ?? 1);
try {
const child = spawn(resolveNodeRunner(), argv, {
stdio: "inherit",
env: {
...process.env,
[POST_CORE_UPDATE_ENV]: "1",
[POST_CORE_UPDATE_CHANNEL_ENV]: params.channel,
...(resultPath ? { [POST_CORE_UPDATE_RESULT_PATH_ENV]: resultPath } : {}),
},
});
});
if (exitCode !== 0) {
defaultRuntime.exit(exitCode);
throw new Error(`post-update process exited with code ${exitCode}`);
const exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => {
if (signal) {
reject(new Error(`post-update process terminated by signal ${signal}`));
return;
}
resolve(code ?? 1);
});
});
if (exitCode !== 0) {
defaultRuntime.exit(exitCode);
throw new Error(`post-update process exited with code ${exitCode}`);
}
const pluginUpdate = resultPath
? await readPostCorePluginUpdateResultFile(resultPath)
: undefined;
return { resumed: true, ...(pluginUpdate ? { pluginUpdate } : {}) };
} finally {
if (resultDir) {
await fs.rm(resultDir, { recursive: true, force: true }).catch(() => undefined);
}
}
return true;
}
function shouldResumePostCoreUpdateInFreshProcess(params: {
@@ -855,12 +986,29 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
return;
}
await runPostCorePluginUpdate({
const pluginUpdate = await runPostCorePluginUpdate({
root,
channel: postCoreUpdateChannel,
configSnapshot: await readConfigFileSnapshot(),
opts,
});
if (opts.json) {
await writePostCorePluginUpdateResultFile(
process.env[POST_CORE_UPDATE_RESULT_PATH_ENV],
pluginUpdate,
);
if (!process.env[POST_CORE_UPDATE_RESULT_PATH_ENV]) {
const result: UpdateRunResult = {
status: pluginUpdate.status === "error" ? "error" : "ok",
mode: "unknown",
root,
steps: [],
durationMs: 0,
postUpdate: { plugins: pluginUpdate },
};
defaultRuntime.writeJson(result);
}
}
return;
}
@@ -1082,7 +1230,9 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
});
stop();
printResult(result, { ...opts, hideSteps: showProgress });
if (!opts.json || result.status !== "ok") {
printResult(result, { ...opts, hideSteps: showProgress });
}
if (result.status === "error") {
defaultRuntime.exit(1);
@@ -1124,6 +1274,8 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
"Switched from a package install to a git checkout. Skipping remaining post-update work in the old CLI process; rerun follow-up commands from the new git install if needed.",
),
);
} else {
defaultRuntime.writeJson(result);
}
defaultRuntime.exit(0);
return;
@@ -1168,6 +1320,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
const postUpdateRoot = result.root ?? root;
let postCorePluginUpdate: PostCorePluginUpdateResult | undefined;
let pluginsUpdatedInFreshProcess = false;
if (
shouldResumePostCoreUpdateInFreshProcess({
@@ -1175,11 +1328,13 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
downgradeRisk,
})
) {
pluginsUpdatedInFreshProcess = await continuePostCoreUpdateInFreshProcess({
const freshProcessResult = await continuePostCoreUpdateInFreshProcess({
root: postUpdateRoot,
channel,
opts,
});
pluginsUpdatedInFreshProcess = freshProcessResult.resumed;
postCorePluginUpdate = freshProcessResult.pluginUpdate;
}
const deferOldProcessPostUpdateWork = switchToGit && result.mode === "git";
@@ -1192,7 +1347,7 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
);
}
} else if (!pluginsUpdatedInFreshProcess) {
await runPostCorePluginUpdate({
postCorePluginUpdate = await runPostCorePluginUpdate({
root: postUpdateRoot,
channel,
configSnapshot: postUpdateConfigSnapshot,
@@ -1246,5 +1401,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
if (!opts.json) {
defaultRuntime.log(theme.muted(pickUpdateQuip()));
} else {
defaultRuntime.writeJson({
...result,
...(postCorePluginUpdate ? { postUpdate: { plugins: postCorePluginUpdate } } : {}),
});
}
}