mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 22:30:43 +00:00
fix: fail closed on plugin integrity drift
This commit is contained in:
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user