mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix(plugins): repair peer links after npm updates
This commit is contained in:
committed by
Peter Steinberger
parent
eecda912ee
commit
fb42c722f0
@@ -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.
|
||||
|
||||
@@ -53,9 +53,19 @@ export async function readInstalledPackageVersion(dir: string): Promise<string |
|
||||
return typeof manifest?.version === "string" ? manifest.version : undefined;
|
||||
}
|
||||
|
||||
export function installedPackageNeedsOpenClawPeerLinkRepair(dir: string): boolean {
|
||||
export function readInstalledPackagePeerDependencies(dir: string): Record<string, string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<typeof installPluginFromNpmSpec>[0],
|
||||
): Promise<Awaited<ReturnType<typeof installPluginFromNpmSpec>>> => {
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user