mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 12:20:44 +00:00
fix: fail closed on plugin integrity drift
This commit is contained in:
@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper `loadConfig()` calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.
|
||||
- CLI/channels: preserve bundled setup promotion metadata when a loaded partial channel plugin omits it, so adding a non-default account still moves legacy single-account fields such as Telegram `streaming` into `accounts.default`.
|
||||
- Telegram: keep the sent-message ownership cache isolated per configured session store, so own-message reaction filtering remains correct with custom `session.store` paths.
|
||||
- Security/update: fail closed when exact pinned npm plugin or hook-pack updates detect integrity drift, and expose aborted plugin drift details in `openclaw update --json`.
|
||||
- Ollama: forward OpenClaw thinking control to native `/api/chat` requests as top-level `think`, so `/think off` and `openclaw agent --thinking off` suppress thinking on models such as qwen3 instead of idling until the watchdog fires. Fixes #69902. (#69967) Thanks @WZH8898.
|
||||
- Memory-core/dreaming: suppress the startup-only managed dreaming cron unavailable warning when the cron service is still attaching, while preserving the runtime warning if cron genuinely remains unavailable. Fixes #69939. (#69941) Thanks @Sanjays2402.
|
||||
- Mattermost: suppress reasoning-only payloads even when they arrive as blockquoted `> Reasoning:` text, preventing `/reasoning on` from leaking thinking into channel posts. (#69927) Thanks @lawrence3699.
|
||||
|
||||
@@ -244,8 +244,10 @@ record, updates that installed plugin, and records the new npm spec for future
|
||||
id-based updates.
|
||||
|
||||
When a stored integrity hash exists and the fetched artifact hash changes,
|
||||
OpenClaw prints a warning and asks for confirmation before proceeding. Use
|
||||
global `--yes` to bypass prompts in CI/non-interactive runs.
|
||||
OpenClaw treats that as npm artifact drift. The interactive
|
||||
`openclaw plugins update` command prints the expected and actual hashes and asks
|
||||
for confirmation before proceeding. Non-interactive update helpers fail closed
|
||||
unless the caller supplies an explicit continuation policy.
|
||||
|
||||
`--dangerously-force-unsafe-install` is also available on `plugins update` as a
|
||||
break-glass override for built-in dangerous-code scan false positives during
|
||||
|
||||
@@ -36,7 +36,9 @@ openclaw --update
|
||||
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
|
||||
- `--tag <dist-tag|version|spec>`: override the package target for this update only. For package installs, `main` maps to `github:openclaw/openclaw#main`.
|
||||
- `--dry-run`: preview planned update actions (channel/tag/target/restart flow) without writing config, installing, syncing plugins, or restarting.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON.
|
||||
- `--json`: print machine-readable `UpdateRunResult` JSON, including
|
||||
`postUpdate.plugins.integrityDrifts` when npm plugin artifact drift is
|
||||
detected during post-update plugin sync.
|
||||
- `--timeout <seconds>`: per-step timeout (default is 1200s).
|
||||
- `--yes`: skip confirmation prompts (for example downgrade confirmation)
|
||||
|
||||
@@ -101,6 +103,11 @@ High-level:
|
||||
8. Runs `openclaw doctor` as the final “safe update” check.
|
||||
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||
|
||||
If an exact pinned npm plugin update resolves to an artifact whose integrity
|
||||
differs from the stored install record, `openclaw update` aborts that plugin
|
||||
artifact update instead of installing it. Reinstall or update the plugin
|
||||
explicitly only after verifying that you trust the new artifact.
|
||||
|
||||
If pnpm bootstrap still fails, the updater now stops early with a package-manager-specific error instead of trying `npm run build` inside the checkout.
|
||||
|
||||
## `--update` shorthand
|
||||
|
||||
@@ -545,6 +545,109 @@ describe("update-cli", () => {
|
||||
expect(spawn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses a fail-closed integrity policy for post-core plugin updates", async () => {
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_UPDATE_POST_CORE: "1",
|
||||
OPENCLAW_UPDATE_POST_CORE_CHANNEL: "stable",
|
||||
},
|
||||
async () => {
|
||||
await updateCommand({ restart: false });
|
||||
},
|
||||
);
|
||||
|
||||
const updateCall = updateNpmInstalledPlugins.mock.calls[0]?.[0] as
|
||||
| {
|
||||
onIntegrityDrift?: (drift: {
|
||||
pluginId: string;
|
||||
spec: string;
|
||||
expectedIntegrity: string;
|
||||
actualIntegrity: string;
|
||||
resolvedSpec?: string;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
| undefined;
|
||||
const onIntegrityDrift = updateCall?.onIntegrityDrift;
|
||||
expect(onIntegrityDrift).toBeTypeOf("function");
|
||||
if (!onIntegrityDrift) {
|
||||
throw new Error("missing integrity drift handler");
|
||||
}
|
||||
|
||||
vi.mocked(runtimeCapture.log).mockClear();
|
||||
await expect(
|
||||
onIntegrityDrift({
|
||||
pluginId: "demo",
|
||||
spec: "@openclaw/demo@1.0.0",
|
||||
resolvedSpec: "@openclaw/demo@1.0.0",
|
||||
expectedIntegrity: "sha512-old",
|
||||
actualIntegrity: "sha512-new",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
const logs = vi.mocked(runtimeCapture.log).mock.calls.map((call) => String(call[0]));
|
||||
expect(logs.join("\n")).toContain("Plugin update aborted");
|
||||
});
|
||||
|
||||
it("includes plugin integrity drift details in update json output", async () => {
|
||||
updateNpmInstalledPlugins.mockImplementationOnce(
|
||||
async (params: {
|
||||
config: OpenClawConfig;
|
||||
onIntegrityDrift?: (drift: {
|
||||
pluginId: string;
|
||||
spec: string;
|
||||
resolvedSpec?: string;
|
||||
resolvedVersion?: string;
|
||||
expectedIntegrity: string;
|
||||
actualIntegrity: string;
|
||||
dryRun: boolean;
|
||||
}) => Promise<boolean>;
|
||||
}) => {
|
||||
const proceed = await params.onIntegrityDrift?.({
|
||||
pluginId: "demo",
|
||||
spec: "@openclaw/demo@1.0.0",
|
||||
resolvedSpec: "@openclaw/demo@1.0.0",
|
||||
resolvedVersion: "1.0.0",
|
||||
expectedIntegrity: "sha512-old",
|
||||
actualIntegrity: "sha512-new",
|
||||
dryRun: false,
|
||||
});
|
||||
return {
|
||||
changed: false,
|
||||
config: params.config,
|
||||
outcomes: [
|
||||
{
|
||||
pluginId: "demo",
|
||||
status: "error",
|
||||
message:
|
||||
proceed === false
|
||||
? "Failed to update demo: aborted: npm package integrity drift detected for @openclaw/demo@1.0.0"
|
||||
: "unexpected drift continuation",
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.mocked(defaultRuntime.writeJson).mockClear();
|
||||
|
||||
await updateCommand({ json: true, restart: false });
|
||||
|
||||
const jsonOutput = vi.mocked(defaultRuntime.writeJson).mock.calls.at(-1)?.[0] as
|
||||
| UpdateRunResult
|
||||
| undefined;
|
||||
expect(jsonOutput?.postUpdate?.plugins?.integrityDrifts).toEqual([
|
||||
{
|
||||
pluginId: "demo",
|
||||
spec: "@openclaw/demo@1.0.0",
|
||||
resolvedSpec: "@openclaw/demo@1.0.0",
|
||||
resolvedVersion: "1.0.0",
|
||||
expectedIntegrity: "sha512-old",
|
||||
actualIntegrity: "sha512-new",
|
||||
action: "aborted",
|
||||
},
|
||||
]);
|
||||
expect(jsonOutput?.postUpdate?.plugins?.status).toBe("error");
|
||||
expect(jsonOutput?.postUpdate?.plugins?.npm.outcomes[0]?.status).toBe("error");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "preview mode",
|
||||
|
||||
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
98
src/hooks/update.test.ts
Normal file
98
src/hooks/update.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { HookNpmIntegrityDriftParams } from "./install.js";
|
||||
|
||||
const installHooksFromNpmSpecMock = vi.fn();
|
||||
|
||||
vi.mock("./install.js", () => ({
|
||||
installHooksFromNpmSpec: (...args: unknown[]) => installHooksFromNpmSpecMock(...args),
|
||||
resolveHookInstallDir: (hookId: string) => `/tmp/hooks/${hookId}`,
|
||||
}));
|
||||
|
||||
const { updateNpmInstalledHookPacks } = await import("./update.js");
|
||||
|
||||
function createHookInstallConfig(params: {
|
||||
hookId: string;
|
||||
spec: string;
|
||||
integrity?: string;
|
||||
}): OpenClawConfig {
|
||||
return {
|
||||
hooks: {
|
||||
internal: {
|
||||
installs: {
|
||||
[params.hookId]: {
|
||||
source: "npm",
|
||||
spec: params.spec,
|
||||
installPath: `/tmp/hooks/${params.hookId}`,
|
||||
...(params.integrity ? { integrity: params.integrity } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("updateNpmInstalledHookPacks", () => {
|
||||
beforeEach(() => {
|
||||
installHooksFromNpmSpecMock.mockReset();
|
||||
});
|
||||
|
||||
it("aborts exact pinned hook pack updates on integrity drift by default", async () => {
|
||||
const warn = vi.fn();
|
||||
installHooksFromNpmSpecMock.mockImplementation(
|
||||
async (params: {
|
||||
spec: string;
|
||||
onIntegrityDrift?: (drift: HookNpmIntegrityDriftParams) => boolean | Promise<boolean>;
|
||||
}) => {
|
||||
const proceed = await params.onIntegrityDrift?.({
|
||||
spec: params.spec,
|
||||
expectedIntegrity: "sha512-old",
|
||||
actualIntegrity: "sha512-new",
|
||||
resolution: {
|
||||
integrity: "sha512-new",
|
||||
resolvedSpec: "@openclaw/demo-hooks@1.0.0",
|
||||
version: "1.0.0",
|
||||
},
|
||||
});
|
||||
if (proceed === false) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "aborted: npm package integrity drift detected for @openclaw/demo-hooks@1.0.0",
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
hookPackId: "demo-hooks",
|
||||
hooks: ["demo"],
|
||||
targetDir: "/tmp/hooks/demo-hooks",
|
||||
version: "1.0.0",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const config = createHookInstallConfig({
|
||||
hookId: "demo-hooks",
|
||||
spec: "@openclaw/demo-hooks@1.0.0",
|
||||
integrity: "sha512-old",
|
||||
});
|
||||
const result = await updateNpmInstalledHookPacks({
|
||||
config,
|
||||
hookIds: ["demo-hooks"],
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Integrity drift for hook pack "demo-hooks" (@openclaw/demo-hooks@1.0.0): expected sha512-old, got sha512-new',
|
||||
);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.config).toBe(config);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
hookId: "demo-hooks",
|
||||
status: "error",
|
||||
message:
|
||||
'Failed to update hook pack "demo-hooks": aborted: npm package integrity drift detected for @openclaw/demo-hooks@1.0.0',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -61,7 +61,7 @@ function createHookPackUpdateIntegrityDriftHandler(params: {
|
||||
params.logger.warn?.(
|
||||
`Integrity drift for hook pack "${params.hookId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
|
||||
);
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,39 @@ export type UpdateRunResult = {
|
||||
after?: { sha?: string | null; version?: string | null };
|
||||
steps: UpdateStepResult[];
|
||||
durationMs: number;
|
||||
postUpdate?: {
|
||||
plugins?: {
|
||||
status: "ok" | "skipped" | "error";
|
||||
reason?: string;
|
||||
changed: boolean;
|
||||
sync: {
|
||||
changed: boolean;
|
||||
switchedToBundled: string[];
|
||||
switchedToNpm: string[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
};
|
||||
npm: {
|
||||
changed: boolean;
|
||||
outcomes: Array<{
|
||||
pluginId: string;
|
||||
status: "updated" | "unchanged" | "skipped" | "error";
|
||||
message: string;
|
||||
currentVersion?: string;
|
||||
nextVersion?: string;
|
||||
}>;
|
||||
};
|
||||
integrityDrifts: Array<{
|
||||
pluginId: string;
|
||||
spec: string;
|
||||
expectedIntegrity: string;
|
||||
actualIntegrity: string;
|
||||
resolvedSpec?: string;
|
||||
resolvedVersion?: string;
|
||||
action: "aborted";
|
||||
}>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CommandRunner = (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { bundledPluginRootAt } from "../../test/helpers/bundled-plugin-paths.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { PluginNpmIntegrityDriftParams } from "./install.js";
|
||||
|
||||
const APP_ROOT = "/app";
|
||||
|
||||
@@ -304,6 +305,60 @@ describe("updateNpmInstalledPlugins", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("aborts exact pinned npm plugin updates on integrity drift by default", async () => {
|
||||
const warn = vi.fn();
|
||||
installPluginFromNpmSpecMock.mockImplementation(
|
||||
async (params: {
|
||||
spec: string;
|
||||
onIntegrityDrift?: (drift: PluginNpmIntegrityDriftParams) => boolean | Promise<boolean>;
|
||||
}) => {
|
||||
const proceed = await params.onIntegrityDrift?.({
|
||||
spec: params.spec,
|
||||
expectedIntegrity: "sha512-old",
|
||||
actualIntegrity: "sha512-new",
|
||||
resolution: {
|
||||
integrity: "sha512-new",
|
||||
resolvedSpec: "@opik/opik-openclaw@0.2.5",
|
||||
version: "0.2.5",
|
||||
},
|
||||
});
|
||||
if (proceed === false) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "aborted: npm package integrity drift detected for @opik/opik-openclaw@0.2.5",
|
||||
};
|
||||
}
|
||||
return createSuccessfulNpmUpdateResult();
|
||||
},
|
||||
);
|
||||
|
||||
const config = createNpmInstallConfig({
|
||||
pluginId: "opik-openclaw",
|
||||
spec: "@opik/opik-openclaw@0.2.5",
|
||||
integrity: "sha512-old",
|
||||
installPath: "/tmp/opik-openclaw",
|
||||
});
|
||||
const result = await updateNpmInstalledPlugins({
|
||||
config,
|
||||
pluginIds: ["opik-openclaw"],
|
||||
logger: { warn },
|
||||
});
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'Integrity drift for "opik-openclaw" (@opik/opik-openclaw@0.2.5): expected sha512-old, got sha512-new',
|
||||
);
|
||||
expect(result.changed).toBe(false);
|
||||
expect(result.config).toBe(config);
|
||||
expect(result.outcomes).toEqual([
|
||||
{
|
||||
pluginId: "opik-openclaw",
|
||||
status: "error",
|
||||
message:
|
||||
"Failed to update opik-openclaw: aborted: npm package integrity drift detected for @opik/opik-openclaw@0.2.5",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "formats package-not-found updates with a stable message",
|
||||
|
||||
@@ -248,7 +248,7 @@ function createPluginUpdateIntegrityDriftHandler(params: {
|
||||
params.logger.warn?.(
|
||||
`Integrity drift for "${params.pluginId}" (${payload.resolvedSpec ?? payload.spec}): expected ${payload.expectedIntegrity}, got ${payload.actualIntegrity}`,
|
||||
);
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user