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

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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",

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 } } : {}),
});
}
}

98
src/hooks/update.test.ts Normal file
View 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',
},
]);
});
});

View File

@@ -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;
};
}

View File

@@ -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 = (

View File

@@ -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",

View File

@@ -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;
};
}