Skip to content

Commit fb42c72

Browse files
ProspectOresteipete
authored andcommitted
fix(plugins): repair peer links after npm updates
1 parent eecda91 commit fb42c72

4 files changed

Lines changed: 233 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Docs: https://docs.openclaw.ai
126126
- 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.
127127
- 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)
128128
- 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.
129+
- 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.
129130
- 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.
130131
- 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.
131132
- 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.

src/infra/package-update-utils.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,19 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
5353
return typeof manifest?.version === "string" ? manifest.version : undefined;
5454
}
5555

56-
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
56+
export function readInstalledPackagePeerDependencies(dir: string): Record<string, string> {
5757
const manifest = readInstalledPackageManifest(dir);
5858
const peerDependencies = isRecord(manifest?.peerDependencies) ? manifest.peerDependencies : {};
59+
return Object.fromEntries(
60+
Object.entries(peerDependencies).filter((entry): entry is [string, string] => {
61+
const [, value] = entry;
62+
return typeof value === "string";
63+
}),
64+
);
65+
}
66+
67+
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
68+
const peerDependencies = readInstalledPackagePeerDependencies(dir);
5969
if (!Object.hasOwn(peerDependencies, "openclaw")) {
6070
return false;
6171
}

src/plugins/update.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,28 @@ function createInstalledPackageDir(params: {
272272
return dir;
273273
}
274274

275+
function createOpenClawPeerLinkFixtures(plugins: Array<{ pluginId: string; packageName: string }>) {
276+
const peerTarget = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-peer-target-"));
277+
tempDirs.push(peerTarget);
278+
const installPaths = Object.fromEntries(
279+
plugins.map(({ pluginId, packageName }) => [
280+
pluginId,
281+
createInstalledPackageDir({
282+
name: packageName,
283+
version: "2026.5.4",
284+
peerDependencies: { openclaw: ">=2026.5.4" },
285+
}),
286+
]),
287+
);
288+
const peerLinkPath = (pluginId: string) =>
289+
path.join(installPaths[pluginId]!, "node_modules", "openclaw");
290+
const linkPeer = (pluginId: string) => {
291+
fs.mkdirSync(path.dirname(peerLinkPath(pluginId)), { recursive: true });
292+
fs.symlinkSync(peerTarget, peerLinkPath(pluginId), "junction");
293+
};
294+
return { installPaths, peerLinkPath, linkPeer };
295+
}
296+
275297
function mockNpmViewMetadata(params: {
276298
name: string;
277299
version: string;
@@ -833,6 +855,145 @@ describe("updateNpmInstalledPlugins", () => {
833855
]);
834856
});
835857

858+
it("repairs openclaw peer links after batch npm updates prune earlier plugin links", async () => {
859+
const plugins = [
860+
{ pluginId: "brave", packageName: "@openclaw/brave-plugin" },
861+
{ pluginId: "codex", packageName: "@openclaw/codex" },
862+
{ pluginId: "discord", packageName: "@openclaw/discord" },
863+
];
864+
const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins);
865+
for (const { packageName } of plugins) {
866+
mockNpmViewMetadata({
867+
name: packageName,
868+
version: "2026.5.4",
869+
integrity: "sha512-same",
870+
shasum: "same",
871+
});
872+
}
873+
installPluginFromNpmSpecMock.mockImplementation(
874+
(params: { expectedPluginId?: string; spec: string }) => {
875+
const pluginId = params.expectedPluginId!;
876+
for (const { pluginId: installedPluginId } of plugins) {
877+
fs.rmSync(peerLinkPath(installedPluginId), { recursive: true, force: true });
878+
}
879+
linkPeer(pluginId);
880+
const packageName = plugins.find((plugin) => plugin.pluginId === pluginId)!.packageName;
881+
return Promise.resolve(
882+
createSuccessfulNpmUpdateResult({
883+
pluginId,
884+
targetDir: installPaths[pluginId],
885+
version: "2026.5.4",
886+
npmResolution: {
887+
name: packageName,
888+
version: "2026.5.4",
889+
resolvedSpec: `${packageName}@2026.5.4`,
890+
},
891+
}),
892+
);
893+
},
894+
);
895+
896+
const result = await updateNpmInstalledPlugins({
897+
config: {
898+
plugins: {
899+
installs: Object.fromEntries(
900+
plugins.map(({ pluginId, packageName }) => [
901+
pluginId,
902+
{
903+
source: "npm",
904+
spec: packageName,
905+
installPath: installPaths[pluginId],
906+
resolvedName: packageName,
907+
resolvedVersion: "2026.5.4",
908+
resolvedSpec: `${packageName}@2026.5.4`,
909+
integrity: "sha512-same",
910+
shasum: "same",
911+
},
912+
]),
913+
),
914+
},
915+
},
916+
pluginIds: plugins.map((plugin) => plugin.pluginId),
917+
});
918+
919+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(3);
920+
for (const { pluginId } of plugins) {
921+
expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true);
922+
}
923+
expect(result.outcomes).toEqual(
924+
plugins.map(({ pluginId }) => ({
925+
pluginId,
926+
status: "unchanged",
927+
currentVersion: "2026.5.4",
928+
nextVersion: "2026.5.4",
929+
message: `${pluginId} already at 2026.5.4.`,
930+
})),
931+
);
932+
});
933+
934+
it("repairs sibling openclaw peer links after a targeted npm update prunes the shared install tree", async () => {
935+
const plugins = [
936+
{ pluginId: "brave", packageName: "@openclaw/brave-plugin" },
937+
{ pluginId: "codex", packageName: "@openclaw/codex" },
938+
{ pluginId: "discord", packageName: "@openclaw/discord" },
939+
];
940+
const { installPaths, peerLinkPath, linkPeer } = createOpenClawPeerLinkFixtures(plugins);
941+
linkPeer("brave");
942+
linkPeer("discord");
943+
mockNpmViewMetadata({
944+
name: "@openclaw/codex",
945+
version: "2026.5.4",
946+
integrity: "sha512-same",
947+
shasum: "same",
948+
});
949+
installPluginFromNpmSpecMock.mockImplementation(() => {
950+
for (const { pluginId } of plugins) {
951+
fs.rmSync(peerLinkPath(pluginId), { recursive: true, force: true });
952+
}
953+
linkPeer("codex");
954+
return Promise.resolve(
955+
createSuccessfulNpmUpdateResult({
956+
pluginId: "codex",
957+
targetDir: installPaths.codex,
958+
version: "2026.5.4",
959+
npmResolution: {
960+
name: "@openclaw/codex",
961+
version: "2026.5.4",
962+
resolvedSpec: "@openclaw/codex@2026.5.4",
963+
},
964+
}),
965+
);
966+
});
967+
968+
await updateNpmInstalledPlugins({
969+
config: {
970+
plugins: {
971+
installs: Object.fromEntries(
972+
plugins.map(({ pluginId, packageName }) => [
973+
pluginId,
974+
{
975+
source: "npm",
976+
spec: packageName,
977+
installPath: installPaths[pluginId],
978+
resolvedName: packageName,
979+
resolvedVersion: "2026.5.4",
980+
resolvedSpec: `${packageName}@2026.5.4`,
981+
integrity: "sha512-same",
982+
shasum: "same",
983+
},
984+
]),
985+
),
986+
},
987+
},
988+
pluginIds: ["codex"],
989+
});
990+
991+
expect(installPluginFromNpmSpecMock).toHaveBeenCalledTimes(1);
992+
for (const { pluginId } of plugins) {
993+
expect(fs.existsSync(peerLinkPath(pluginId))).toBe(true);
994+
}
995+
});
996+
836997
it("refreshes legacy npm install records before skipping unchanged artifacts", async () => {
837998
const installPath = createInstalledPackageDir({
838999
name: "@martian-engineering/lossless-claw",

src/plugins/update.ts

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import {
1313
expectedIntegrityForUpdate,
1414
installedPackageNeedsOpenClawPeerLinkRepair,
15+
readInstalledPackagePeerDependencies,
1516
readInstalledPackageVersion,
1617
} from "../infra/package-update-utils.js";
1718
import { compareComparableSemver, parseComparableSemver } from "../infra/semver-compare.js";
@@ -46,6 +47,7 @@ import {
4647
getOfficialExternalPluginCatalogEntry,
4748
resolveOfficialExternalPluginInstall,
4849
} from "./official-external-plugin-catalog.js";
50+
import { linkOpenClawPeerDependencies } from "./plugin-peer-link.js";
4951

5052
export type PluginUpdateLogger = {
5153
info?: (message: string) => void;
@@ -758,6 +760,47 @@ function disablePluginConfigEntry(config: OpenClawConfig, pluginId: string): Ope
758760
};
759761
}
760762

763+
async function repairOpenClawPeerLinksForNpmInstalls(params: {
764+
config: OpenClawConfig;
765+
logger: PluginUpdateLogger;
766+
}): Promise<boolean> {
767+
let repaired = false;
768+
for (const [pluginId, record] of Object.entries(params.config.plugins?.installs ?? {})) {
769+
if (record.source !== "npm") {
770+
continue;
771+
}
772+
773+
let installPath: string;
774+
try {
775+
installPath = resolveUserPath(
776+
record.installPath?.trim() || resolvePluginInstallDir(pluginId),
777+
);
778+
} catch (err) {
779+
params.logger.warn?.(
780+
`Could not repair openclaw peer link for "${pluginId}" due to invalid install path: ${String(err)}`,
781+
);
782+
continue;
783+
}
784+
785+
if (!installedPackageNeedsOpenClawPeerLinkRepair(installPath)) {
786+
continue;
787+
}
788+
789+
const peerDependencies = readInstalledPackagePeerDependencies(installPath);
790+
if (!Object.hasOwn(peerDependencies, "openclaw")) {
791+
continue;
792+
}
793+
794+
await linkOpenClawPeerDependencies({
795+
installedDir: installPath,
796+
peerDependencies,
797+
logger: params.logger,
798+
});
799+
repaired = !installedPackageNeedsOpenClawPeerLinkRepair(installPath) || repaired;
800+
}
801+
return repaired;
802+
}
803+
761804
export async function updateNpmInstalledPlugins(params: {
762805
config: OpenClawConfig;
763806
logger?: PluginUpdateLogger;
@@ -783,6 +826,13 @@ export async function updateNpmInstalledPlugins(params: {
783826
const outcomes: PluginUpdateOutcome[] = [];
784827
let next = params.config;
785828
let changed = false;
829+
let ranNpmInstaller = false;
830+
const installNpmSpecForUpdate = async (
831+
installParams: Parameters<typeof installPluginFromNpmSpec>[0],
832+
): Promise<Awaited<ReturnType<typeof installPluginFromNpmSpec>>> => {
833+
ranNpmInstaller = true;
834+
return await installPluginFromNpmSpec(installParams);
835+
};
786836

787837
const recordFailure = (pluginId: string, message: string) => {
788838
if (params.disableOnFailure && !params.dryRun) {
@@ -1219,7 +1269,7 @@ export async function updateNpmInstalledPlugins(params: {
12191269
try {
12201270
result =
12211271
record.source === "npm"
1222-
? await installPluginFromNpmSpec({
1272+
? await installNpmSpecForUpdate({
12231273
spec: effectiveSpec!,
12241274
mode: "update",
12251275
extensionsDir,
@@ -1282,7 +1332,7 @@ export async function updateNpmInstalledPlugins(params: {
12821332
}),
12831333
);
12841334
usedNpmFallback = true;
1285-
result = await installPluginFromNpmSpec({
1335+
result = await installNpmSpecForUpdate({
12861336
spec: npmSpecs.fallbackSpec,
12871337
mode: "update",
12881338
extensionsDir,
@@ -1440,6 +1490,14 @@ export async function updateNpmInstalledPlugins(params: {
14401490
}
14411491
}
14421492

1493+
if (ranNpmInstaller) {
1494+
changed =
1495+
(await repairOpenClawPeerLinksForNpmInstalls({
1496+
config: next,
1497+
logger,
1498+
})) || changed;
1499+
}
1500+
14431501
return { config: next, changed, outcomes };
14441502
}
14451503

0 commit comments

Comments
 (0)