From fb42c722f0f52c43ce836533cb9633d3e9d7af67 Mon Sep 17 00:00:00 2001 From: pickaxe <54486432+ProspectOre@users.noreply.github.com> Date: Tue, 5 May 2026 02:45:54 -0700 Subject: [PATCH] fix(plugins): repair peer links after npm updates --- CHANGELOG.md | 1 + src/infra/package-update-utils.ts | 12 ++- src/plugins/update.test.ts | 161 ++++++++++++++++++++++++++++++ src/plugins/update.ts | 62 +++++++++++- 4 files changed, 233 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1b379457eb..07fb59bcd6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ Docs: https://docs.openclaw.ai - Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires. - Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754) - WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn. +- Plugins/update: repair plugin-local `openclaw` peer links for all recorded npm plugins after any npm update mutates the shared managed npm tree, so targeted or batch updates cannot leave Codex, Discord, or Brave with pruned SDK imports. (#77787) Thanks @ProspectOre. - TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc. - Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd. - Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd. diff --git a/src/infra/package-update-utils.ts b/src/infra/package-update-utils.ts index 4cc5c564d18..2f2ac5067ea 100644 --- a/src/infra/package-update-utils.ts +++ b/src/infra/package-update-utils.ts @@ -53,9 +53,19 @@ export async function readInstalledPackageVersion(dir: string): Promise { const manifest = readInstalledPackageManifest(dir); const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {}; + return Object.fromEntries( + Object.entries(peerDependencies).filter((entry): entry is [string, string] => { + const [, value] = entry; + return typeof value === "string"; + }), + ); +} + +export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean { + const peerDependencies = readInstalledPackagePeerDependencies(dir); if (!Object.hasOwn(peerDependencies, "openclaw")) { return false; } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 8a26cb634df..cc89b5a36d0 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -272,6 +272,28 @@ function createInstalledPackageDir(params: { return dir; } +function createOpenClawPeerLinkFixtures(plugins: Array<{ pluginId: string; packageName: string }>) { + const peerTarget = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-peer-target-")); + tempDirs.push(peerTarget); + const installPaths = Object.fromEntries( + plugins.map(({ pluginId, packageName }) => [ + pluginId, + createInstalledPackageDir({ + name: packageName, + version: "2026.5.4", + peerDependencies: { openclaw: ">=2026.5.4" }, + }), + ]), + ); + const peerLinkPath = (pluginId: string) => + path.join(installPaths[pluginId]!, "node_modules", "openclaw"); + const linkPeer = (pluginId: string) => { + fs.mkdirSync(path.dirname(peerLinkPath(pluginId)), { recursive: true }); + fs.symlinkSync(peerTarget, peerLinkPath(pluginId), "junction"); + }; + return { installPaths, peerLinkPath, linkPeer }; +} + function mockNpmViewMetadata(params: { name: string; version: string; @@ -833,6 +855,145 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("repairs openclaw peer links after batch npm updates prune earlier plugin links", async () => { + const plugins = [ + { pluginId: "brave", packageName: "@openclaw/brave-plugin" }, + { pluginId: "codex", packageName: "@openclaw/codex" }, + { pluginId: "discord", packageName: "@openclaw/discord" }, + ]; + const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins); + for (const { packageName } of plugins) { + mockNpmViewMetadata({ + name: packageName, + version: "2026.5.4", + integrity: "sha512-same", + shasum: "same", + }); + } + installPluginFromNpmSpecMock.mockImplementation( + (params: { expectedPluginId?: string; spec: string }) => { + const pluginId = params.expectedPluginId!; + for (const { pluginId: installedPluginId } of plugins) { + fs.rmSync(peerLinkPath(installedPluginId), { recursive: true, force: true }); + } + linkPeer(pluginId); + const packageName = plugins.find((plugin) => plugin.pluginId === pluginId)!.packageName; + return Promise.resolve( + createSuccessfulNpmUpdateResult({ + pluginId, + targetDir: installPaths[pluginId], + version: "2026.5.4", + npmResolution: { + name: packageName, + version: "2026.5.4", + resolvedSpec: `${packageName}@2026.5.4`, + }, + }), + ); + }, + ); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: Object.fromEntries( + plugins.map(({ pluginId, packageName }) => [ + pluginId, + { + source: "npm", + spec: packageName, + installPath: installPaths[pluginId], + resolvedName: packageName, + resolvedVersion: "2026.5.4", + resolvedSpec: `${packageName}@2026.5.4`, + integrity: "sha512-same", + shasum: "same", + }, + ]), + ), + }, + }, + pluginIds: plugins.map((plugin) => plugin.pluginId), + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(3); + for (const { pluginId } of plugins) { + expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true); + } + expect(result.outcomes).toEqual( + plugins.map(({ pluginId }) => ({ + pluginId, + status: "unchanged", + currentVersion: "2026.5.4", + nextVersion: "2026.5.4", + message: `${pluginId} already at 2026.5.4.`, + })), + ); + }); + + it("repairs sibling openclaw peer links after a targeted npm update prunes the shared install tree", async () => { + const plugins = [ + { pluginId: "brave", packageName: "@openclaw/brave-plugin" }, + { pluginId: "codex", packageName: "@openclaw/codex" }, + { pluginId: "discord", packageName: "@openclaw/discord" }, + ]; + const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins); + linkPeer("brave"); + linkPeer("discord"); + mockNpmViewMetadata({ + name: "@openclaw/codex", + version: "2026.5.4", + integrity: "sha512-same", + shasum: "same", + }); + installPluginFromNpmSpecMock.mockImplementation(() => { + for (const { pluginId } of plugins) { + fs.rmSync(peerLinkPath(pluginId), { recursive: true, force: true }); + } + linkPeer("codex"); + return Promise.resolve( + createSuccessfulNpmUpdateResult({ + pluginId: "codex", + targetDir: installPaths.codex, + version: "2026.5.4", + npmResolution: { + name: "@openclaw/codex", + version: "2026.5.4", + resolvedSpec: "@openclaw/codex@2026.5.4", + }, + }), + ); + }); + + await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: Object.fromEntries( + plugins.map(({ pluginId, packageName }) => [ + pluginId, + { + source: "npm", + spec: packageName, + installPath: installPaths[pluginId], + resolvedName: packageName, + resolvedVersion: "2026.5.4", + resolvedSpec: `${packageName}@2026.5.4`, + integrity: "sha512-same", + shasum: "same", + }, + ]), + ), + }, + }, + pluginIds: ["codex"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1); + for (const { pluginId } of plugins) { + expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true); + } + }); + it("refreshes legacy npm install records before skipping unchanged artifacts", async () => { const installPath = createInstalledPackageDir({ name: "@martian-engineering/lossless-claw", diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 1a49347d055..898d90db3b9 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -12,6 +12,7 @@ import { import { expectedIntegrityForUpdate, installedPackageNeedsOpenClawPeerLinkRepair, + readInstalledPackagePeerDependencies, readInstalledPackageVersion, } from "../infra/package-update-utils.js"; import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js"; @@ -46,6 +47,7 @@ import { getOfficialExternalPluginCatalogEntry, resolveOfficialExternalPluginInstall, } from "./official-external-plugin-catalog.js"; +import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js"; export type PluginUpdateLogger = { info?: (message: string) => void; @@ -758,6 +760,47 @@ function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): Ope }; } +async function repairOpenClawPeerLinksForNpmInstalls(params: { + config: OpenClawConfig; + logger: PluginUpdateLogger; +}): Promise { + let repaired = false; + for (const [pluginId, record] of Object.entries(params.config.plugins?.installs ?? {})) { + if (record.source !== "npm") { + continue; + } + + let installPath: string; + try { + installPath = resolveUserPath( + record.installPath?.trim() || resolvePluginInstallDir(pluginId), + ); + } catch (err) { + params.logger.warn?.( + `Could not repair openclaw peer link for "${pluginId}" due to invalid install path: ${String(err)}`, + ); + continue; + } + + if (!installedPackageNeedsOpenClawPeerLinkRepair(installPath)) { + continue; + } + + const peerDependencies = readInstalledPackagePeerDependencies(installPath); + if (!Object.hasOwn(peerDependencies, "openclaw")) { + continue; + } + + await linkOpenClawPeerDependencies({ + installedDir: installPath, + peerDependencies, + logger: params.logger, + }); + repaired = !installedPackageNeedsOpenClawPeerLinkRepair(installPath) || repaired; + } + return repaired; +} + export async function updateNpmInstalledPlugins(params: { config: OpenClawConfig; logger?: PluginUpdateLogger; @@ -783,6 +826,13 @@ export async function updateNpmInstalledPlugins(params: { const outcomes: PluginUpdateOutcome[] = []; let next = params.config; let changed = false; + let ranNpmInstaller = false; + const installNpmSpecForUpdate = async ( + installParams: Parameters[0], + ): Promise>> => { + ranNpmInstaller = true; + return await installPluginFromNpmSpec(installParams); + }; const recordFailure = (pluginId: string, message: string) => { if (params.disableOnFailure && !params.dryRun) { @@ -1219,7 +1269,7 @@ export async function updateNpmInstalledPlugins(params: { try { result = record.source === "npm" - ? await installPluginFromNpmSpec({ + ? await installNpmSpecForUpdate({ spec: effectiveSpec!, mode: "update", extensionsDir, @@ -1282,7 +1332,7 @@ export async function updateNpmInstalledPlugins(params: { }), ); usedNpmFallback = true; - result = await installPluginFromNpmSpec({ + result = await installNpmSpecForUpdate({ spec: npmSpecs.fallbackSpec, mode: "update", extensionsDir, @@ -1440,6 +1490,14 @@ export async function updateNpmInstalledPlugins(params: { } } + if (ranNpmInstaller) { + changed = + (await repairOpenClawPeerLinksForNpmInstalls({ + config: next, + logger, + })) || changed; + } + return { config: next, changed, outcomes }; }