diff --git a/CHANGELOG.md b/CHANGELOG.md index 6564330d5a4..bd08c9a9d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 0ed8425cd3a..ebf0d3c76c6 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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 diff --git a/docs/cli/update.md b/docs/cli/update.md index fae3f6d649c..e0adde19814 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -36,7 +36,9 @@ openclaw --update - `--channel `: set the update channel (git + npm; persisted in config). - `--tag `: 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 `: 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 diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 2b1d54df8bb..51823ca55ca 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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; + } + | 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; + }) => { + 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", diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 86698318e33..275de1afed6 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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["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>; opts: UpdateCommandOptions; -}): Promise { +}): Promise { 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>; opts: UpdateCommandOptions; -}): Promise { - await updatePluginsAfterCoreUpdate({ +}): Promise { + 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 { + if (!filePath) { + return; + } + await fs.writeFile(filePath, `${JSON.stringify(result)}\n`, "utf-8"); +} + +async function readPostCorePluginUpdateResultFile( + filePath: string, +): Promise { + 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 { +}): 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((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((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 { 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 { }); 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 { "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 { 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 { 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 { ); } } else if (!pluginsUpdatedInFreshProcess) { - await runPostCorePluginUpdate({ + postCorePluginUpdate = await runPostCorePluginUpdate({ root: postUpdateRoot, channel, configSnapshot: postUpdateConfigSnapshot, @@ -1246,5 +1401,10 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise { if (!opts.json) { defaultRuntime.log(theme.muted(pickUpdateQuip())); + } else { + defaultRuntime.writeJson({ + ...result, + ...(postCorePluginUpdate ? { postUpdate: { plugins: postCorePluginUpdate } } : {}), + }); } } diff --git a/src/hooks/update.test.ts b/src/hooks/update.test.ts new file mode 100644 index 00000000000..181d09ac62b --- /dev/null +++ b/src/hooks/update.test.ts @@ -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; + }) => { + 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', + }, + ]); + }); +}); diff --git a/src/hooks/update.ts b/src/hooks/update.ts index 417fcacfded..f9e08cad734 100644 --- a/src/hooks/update.ts +++ b/src/hooks/update.ts @@ -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; }; } diff --git a/src/infra/update-runner.ts b/src/infra/update-runner.ts index 1c1366588b2..e69cf5887d6 100644 --- a/src/infra/update-runner.ts +++ b/src/infra/update-runner.ts @@ -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 = ( diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 8061ff5d120..a5908eb34bd 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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; + }) => { + 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", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 9645b5f17c6..151221c0e9d 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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; }; }