diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c69df1df98..7be6758d2db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -88,6 +88,7 @@ Docs: https://docs.openclaw.ai - Build: replace selected build utility `tsx` preloads with Node native type stripping so Node 26 build paths no longer emit `DEP0205` module loader deprecation warnings. (#78584) Thanks @keshavbotagent. - Media generation: honor configured music and video generation timeouts when tool calls omit `timeoutMs`, matching image generation behavior. (#80687) - CLI/update/status: label beta-channel plugin fallback and model-pricing refresh failures as warnings, keeping mixed beta/latest plugin cohorts visible without making core update or Gateway reachability look failed. Fixes #80689. Thanks @BKF-Gitty. +- Doctor/plugins: relink managed npm plugin `openclaw` peer dependencies during `doctor --fix`, while refusing to follow package-local `node_modules` symlinks outside the plugin package. (#77412) Thanks @TheCrazyLex. - iMessage: route inbound tapbacks as reaction system events instead of normal messages, defaulting to bot-authored-message notifications while allowing `reactionNotifications: "off" | "own" | "all"` overrides. Fixes #60274; refs #39031 and #39322. Thanks @hyperclaw. - Control UI/performance: scope Nodes polling to the active Nodes tab, debounce stale session-list reconciliation, and bound chat-side session refreshes so long-running dashboards avoid background reload churn. Thanks @BunsDev. - Plugins/channels: explain bundled channel entry files that reach the legacy plugin loader as setup-runtime loader mismatches instead of generic missing-register failures. Thanks @chinar-amrutkar. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index e51acf78e04..450dfbffda4 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -57,7 +57,7 @@ Notes: - On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment. - When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops. - Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` moves Codex intent onto provider/model-scoped `agentRuntime.id: "codex"` entries, preserves session auth-profile pins such as `openai-codex:...`, removes stale whole-agent/session runtime pins, and keeps repaired OpenAI agent refs on Codex auth routing instead of direct OpenAI API-key auth. -- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt. +- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions and relinks the host `openclaw` package for managed npm plugins that declare it as a peer dependency. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt. - Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.deny`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy. - Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running. - Set `OPENCLAW_SERVICE_REPAIR_POLICY=external` when another supervisor owns the gateway lifecycle. Doctor still reports gateway/service health and applies non-service repairs, but skips service install/start/restart/bootstrap and legacy service cleanup. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index c177e179987..9106d15c7f8 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -402,7 +402,7 @@ The local plugin registry is OpenClaw's persisted cold read model for installed Use `plugins registry` to inspect whether the persisted registry is present, current, or stale. Use `--refresh` to rebuild it from the persisted plugin index, config policy, and manifest/package metadata. This is a repair path, not a runtime activation path. -`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. +`openclaw doctor --fix` also repairs registry-adjacent managed npm drift: if an orphaned or recovered `@openclaw/*` package under the managed plugin npm root shadows a bundled plugin, doctor removes that stale package and rebuilds the registry so startup validates against the bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` resolve after updates or npm repairs. `OPENCLAW_DISABLE_PERSISTED_PLUGIN_REGISTRY=1` is a deprecated break-glass compatibility switch for registry read failures. Prefer `plugins registry --refresh` or `openclaw doctor --fix`; the env fallback is only for emergency startup recovery while the migration rolls out. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 78d1df36dc1..ecbc13ccdc8 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -360,7 +360,7 @@ That stages grounded durable candidates into the short-term dreaming store while When sandboxing is enabled, doctor checks Docker images and offers to build or switch to legacy names if the current image is missing. - Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. + Doctor removes legacy OpenClaw-generated plugin dependency staging state in `openclaw doctor --fix` / `openclaw doctor --repair` mode. This covers stale generated dependency roots, old install-stage directories, package-local debris from earlier bundled-plugin dependency repair code, and orphaned or recovered managed npm copies of bundled `@openclaw/*` plugins that can shadow the current bundled manifest. Doctor also relinks the host `openclaw` package into managed npm plugins that declare `peerDependencies.openclaw`, so package-local runtime imports such as `openclaw/plugin-sdk/*` keep resolving after updates or npm repairs. Doctor can also reinstall missing downloadable plugins when config references them but the local plugin registry cannot find them. Examples include material `plugins.entries`, configured channel/provider/search settings, and configured agent runtimes. During package updates, doctor avoids running package-manager plugin repair while the core package is being swapped; run `openclaw doctor --fix` again after the update if a configured plugin still needs recovery. Gateway startup and config reload do not run package managers; plugin installs remain explicit doctor/install/update work. diff --git a/src/commands/doctor-plugin-registry.test.ts b/src/commands/doctor-plugin-registry.test.ts index 80a5877642f..6986ec94c64 100644 --- a/src/commands/doctor-plugin-registry.test.ts +++ b/src/commands/doctor-plugin-registry.test.ts @@ -114,6 +114,7 @@ function createManagedNpmPlugin(params: { id: string; packageName: string; version: string; + peerDependencies?: Record; packageLock?: boolean; }) { const npmRoot = path.join(params.stateDir, "npm"); @@ -164,6 +165,7 @@ function createManagedNpmPlugin(params: { JSON.stringify({ name: params.packageName, version: params.version, + ...(params.peerDependencies ? { peerDependencies: params.peerDependencies } : {}), openclaw: { extensions: ["."], }, @@ -506,4 +508,74 @@ describe("maybeRepairPluginRegistryState", () => { expect(packageLock.dependencies).not.toHaveProperty("@openclaw/google-meet"); expect(packageLock.dependencies).toHaveProperty("other-plugin"); }); + + it("repairs managed npm openclaw peer links during registry repair", async () => { + const stateDir = makeTempDir(); + const managed = createManagedNpmPlugin({ + stateDir, + id: "codex", + packageName: "codex-plugin", + version: "2026.5.3", + peerDependencies: { + openclaw: ">=2026.5.3", + }, + }); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithNpmRecord({ + pluginId: "codex", + packageName: "codex-plugin", + packageDir: managed.packageDir, + version: "2026.5.3", + }), + { stateDir }, + ); + + await maybeRepairPluginRegistryState({ + stateDir, + env: hermeticEnv(), + config: {}, + prompter: { shouldRepair: true }, + }); + + const linkPath = path.join(managed.packageDir, "node_modules", "openclaw"); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(process.cwd())); + expect(vi.mocked(note).mock.calls.join("\n")).toContain("Repaired OpenClaw host peer link"); + }); + + it("warns about broken managed npm openclaw peer links without repairing them", async () => { + const stateDir = makeTempDir(); + const managed = createManagedNpmPlugin({ + stateDir, + id: "codex", + packageName: "codex-plugin", + version: "2026.5.3", + peerDependencies: { + openclaw: ">=2026.5.3", + }, + }); + await writePersistedInstalledPluginIndex( + createCurrentIndexWithNpmRecord({ + pluginId: "codex", + packageName: "codex-plugin", + packageDir: managed.packageDir, + version: "2026.5.3", + }), + { stateDir }, + ); + + await maybeRepairPluginRegistryState({ + stateDir, + env: hermeticEnv(), + config: {}, + prompter: { shouldRepair: false }, + }); + + const linkPath = path.join(managed.packageDir, "node_modules", "openclaw"); + const notes = vi.mocked(note).mock.calls.join("\n"); + expect(notes).toContain("Managed npm OpenClaw host peer links need repair"); + expect(notes).toContain("codex-plugin"); + expect(notes).toContain("openclaw doctor --fix"); + expect(fs.existsSync(linkPath)).toBe(false); + }); }); diff --git a/src/commands/doctor-plugin-registry.ts b/src/commands/doctor-plugin-registry.ts index 8cdddd12f85..53bbde474ff 100644 --- a/src/commands/doctor-plugin-registry.ts +++ b/src/commands/doctor-plugin-registry.ts @@ -10,6 +10,10 @@ import { type InstalledPluginIndexRecordStoreOptions, } from "../plugins/installed-plugin-index-records.js"; import { loadInstalledPluginIndex } from "../plugins/installed-plugin-index.js"; +import { + auditOpenClawPeerDependenciesInManagedNpmRoot, + relinkOpenClawPeerDependenciesInManagedNpmRoot, +} from "../plugins/plugin-peer-link.js"; import { refreshPluginRegistry } from "../plugins/plugin-registry.js"; import { note } from "../terminal/note.js"; import { shortenHomePath } from "../utils.js"; @@ -35,6 +39,11 @@ type StaleManagedNpmBundledPlugin = { version?: string; }; +type PluginRegistryDoctorNoteLogger = { + info: (message: string) => void; + warn: (message: string) => void; +}; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -57,6 +66,12 @@ function readStringMap(value: unknown): Record { return result; } +function resolveManagedPluginNpmRoot(params: PluginRegistryDoctorRepairParams): string { + return params.stateDir + ? path.join(params.stateDir, "npm") + : resolveDefaultPluginNpmDir(params.env); +} + function deleteObjectKey(record: Record, key: string): boolean { if (!Object.prototype.hasOwnProperty.call(record, key)) { return false; @@ -87,9 +102,7 @@ function listStaleManagedNpmBundledPlugins( const bundledByPackage = new Map( currentBundled.map((plugin) => [plugin.packageName, plugin] as const), ); - const npmRoot = params.stateDir - ? path.join(params.stateDir, "npm") - : resolveDefaultPluginNpmDir(params.env); + const npmRoot = resolveManagedPluginNpmRoot(params); const npmPackageJsonPath = path.join(npmRoot, "package.json"); const dependencies = readStringMap(readJsonObject(npmPackageJsonPath)?.dependencies); const stale: StaleManagedNpmBundledPlugin[] = []; @@ -228,6 +241,54 @@ export function maybeRepairStaleManagedNpmBundledPlugins( return true; } +export async function maybeRepairManagedNpmOpenClawPeerLinks( + params: PluginRegistryDoctorRepairParams, +): Promise { + const npmRoot = resolveManagedPluginNpmRoot(params); + if (!params.prompter.shouldRepair) { + const audit = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot }); + if (audit.broken > 0) { + note( + [ + "Managed npm OpenClaw host peer links need repair:", + ...audit.issues.map((issue) => `- ${issue.packageName}: ${issue.reason}`), + `Repair with ${formatCliCommand("openclaw doctor --fix")} to relink managed npm plugin packages.`, + ].join("\n"), + "Plugin registry", + ); + } + return false; + } + + const messages: { level: "info" | "warn"; message: string }[] = []; + const logger: PluginRegistryDoctorNoteLogger = { + info: (message) => messages.push({ level: "info", message }), + warn: (message) => messages.push({ level: "warn", message }), + }; + const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({ + npmRoot, + logger, + }); + + if (result.repaired > 0) { + note( + `Repaired OpenClaw host peer link(s) for ${result.repaired} managed npm plugin package(s).`, + "Plugin registry", + ); + } + const warnings = messages + .filter((message) => message.level === "warn") + .map((message) => `- ${message.message}`); + if (warnings.length > 0) { + note( + ["Could not repair all managed npm OpenClaw host peer links:", ...warnings].join("\n"), + "Plugin registry", + ); + } + + return result.repaired > 0; +} + async function loadInstallRecordsWithoutPluginIds( params: PluginRegistryDoctorRepairParams, pluginIds: readonly string[], @@ -262,6 +323,7 @@ export async function maybeRepairPluginRegistryState( (plugin) => plugin.pluginId, ); const removedStaleManagedNpmBundledPlugins = maybeRepairStaleManagedNpmBundledPlugins(params); + const repairedManagedNpmOpenClawPeerLinks = await maybeRepairManagedNpmOpenClawPeerLinks(params); if (!params.prompter.shouldRepair) { if (preflight.action === "migrate") { note( @@ -288,7 +350,11 @@ export async function maybeRepairPluginRegistryState( return params.config; } - if (preflight.action === "skip-existing" || removedStaleManagedNpmBundledPlugins) { + if ( + preflight.action === "skip-existing" || + removedStaleManagedNpmBundledPlugins || + repairedManagedNpmOpenClawPeerLinks + ) { const index = await refreshPluginRegistry({ ...migrationParams, reason: "migration", diff --git a/src/commands/doctor/repair-sequencing.test.ts b/src/commands/doctor/repair-sequencing.test.ts index 666a5ffabd0..b67abf519e6 100644 --- a/src/commands/doctor/repair-sequencing.test.ts +++ b/src/commands/doctor/repair-sequencing.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ getInstalledPluginRecord: vi.fn(), isInstalledPluginEnabled: vi.fn(), loadInstalledPluginIndex: vi.fn(), + maybeRepairManagedNpmOpenClawPeerLinks: vi.fn(), maybeRepairStaleManagedNpmBundledPlugins: vi.fn(), maybeRepairStalePluginConfig: vi.fn(), repairMissingConfiguredPluginInstalls: vi.fn(), @@ -21,6 +22,7 @@ vi.mock("../../config/plugin-auto-enable.js", () => ({ })); vi.mock("../doctor-plugin-registry.js", () => ({ + maybeRepairManagedNpmOpenClawPeerLinks: mocks.maybeRepairManagedNpmOpenClawPeerLinks, maybeRepairStaleManagedNpmBundledPlugins: mocks.maybeRepairStaleManagedNpmBundledPlugins, })); @@ -185,6 +187,7 @@ describe("doctor repair sequencing", () => { mocks.getInstalledPluginRecord.mockReturnValue(undefined); mocks.isInstalledPluginEnabled.mockReturnValue(false); mocks.loadInstalledPluginIndex.mockReturnValue({ plugins: [] }); + mocks.maybeRepairManagedNpmOpenClawPeerLinks.mockResolvedValue(false); mocks.maybeRepairStaleManagedNpmBundledPlugins.mockReturnValue(false); mocks.repairMissingConfiguredPluginInstalls.mockResolvedValue({ changes: [], @@ -264,10 +267,14 @@ describe("doctor repair sequencing", () => { expect(result.warningNotes.join("\n")).not.toContain("\r"); }); - it("removes managed npm bundled-plugin shadows before missing plugin install repair", async () => { + it("repairs managed npm plugin drift before missing plugin install repair", async () => { const events: string[] = []; mocks.maybeRepairStaleManagedNpmBundledPlugins.mockImplementation(() => { - events.push("cleanup"); + events.push("bundled-shadow-cleanup"); + return true; + }); + mocks.maybeRepairManagedNpmOpenClawPeerLinks.mockImplementation(async () => { + events.push("openclaw-peer-links"); return true; }); mocks.repairMissingConfiguredPluginInstalls.mockImplementation(async () => { @@ -297,11 +304,23 @@ describe("doctor repair sequencing", () => { doctorFixCommand: "openclaw doctor --fix", }); - expect(events).toEqual(["cleanup", "missing-installs"]); + expect(events).toEqual(["bundled-shadow-cleanup", "openclaw-peer-links", "missing-installs"]); expect(mocks.maybeRepairStaleManagedNpmBundledPlugins).toHaveBeenCalledOnce(); const cleanupCall = mocks.maybeRepairStaleManagedNpmBundledPlugins.mock.calls[0]?.[0]; expect(cleanupCall?.config.plugins?.entries?.["google-meet"]).toEqual({ enabled: true }); expect(cleanupCall?.prompter).toEqual({ shouldRepair: true }); + expect(mocks.maybeRepairManagedNpmOpenClawPeerLinks).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + plugins: expect.objectContaining({ + entries: expect.objectContaining({ + "google-meet": { enabled: true }, + }), + }), + }), + prompter: { shouldRepair: true }, + }), + ); }); it("emits Discord warnings when unsafe numeric ids block repair", async () => { diff --git a/src/commands/doctor/repair-sequencing.ts b/src/commands/doctor/repair-sequencing.ts index 8ad475a8ebd..c7930c2402d 100644 --- a/src/commands/doctor/repair-sequencing.ts +++ b/src/commands/doctor/repair-sequencing.ts @@ -1,6 +1,9 @@ import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { sanitizeForLog } from "../../terminal/ansi.js"; -import { maybeRepairStaleManagedNpmBundledPlugins } from "../doctor-plugin-registry.js"; +import { + maybeRepairManagedNpmOpenClawPeerLinks, + maybeRepairStaleManagedNpmBundledPlugins, +} from "../doctor-plugin-registry.js"; import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js"; import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js"; import { @@ -74,6 +77,11 @@ export async function runDoctorRepairSequence(params: { env, prompter: { shouldRepair: true }, }); + await maybeRepairManagedNpmOpenClawPeerLinks({ + config: state.candidate, + env, + prompter: { shouldRepair: true }, + }); const codexRouteRepair = maybeRepairCodexRoutes({ cfg: state.candidate, env, diff --git a/src/plugins/install.npm-spec.test.ts b/src/plugins/install.npm-spec.test.ts index 8561ee0accd..2742b4f7072 100644 --- a/src/plugins/install.npm-spec.test.ts +++ b/src/plugins/install.npm-spec.test.ts @@ -718,6 +718,64 @@ describe("installPluginFromNpmSpec", () => { }, ); + it.runIf(process.platform !== "win32")( + "does not fail a managed npm install for an unrelated skipped peer link", + async () => { + const stateDir = suiteTempRootTracker.makeTempDir(); + const npmRoot = path.join(stateDir, "npm"); + const warnings: string[] = []; + + mockNpmViewAndInstallMany([ + { + spec: "peer-plugin@1.0.0", + packageName: "peer-plugin", + version: "1.0.0", + pluginId: "peer-plugin", + npmRoot, + peerDependencies: { openclaw: "^2026.0.0" }, + }, + { + spec: "next-plugin@1.0.0", + packageName: "next-plugin", + version: "1.0.0", + pluginId: "next-plugin", + npmRoot, + }, + ]); + + const first = await installPluginFromNpmSpec({ + spec: "peer-plugin@1.0.0", + npmDir: npmRoot, + logger: { info: () => {}, warn: () => {} }, + }); + expect(first.ok).toBe(true); + + const staleNodeModulesPath = path.join( + npmRoot, + "node_modules", + "peer-plugin", + "node_modules", + ); + fs.rmSync(staleNodeModulesPath, { recursive: true, force: true }); + fs.writeFileSync(staleNodeModulesPath, "not a directory", "utf-8"); + + const second = await installPluginFromNpmSpec({ + spec: "next-plugin@1.0.0", + npmDir: npmRoot, + logger: { info: () => {}, warn: (message) => warnings.push(message) }, + }); + + expect(second.ok).toBe(true); + expect( + warnings.some((warning) => + warning.includes(`Skipping openclaw peerDependency link because ${staleNodeModulesPath}`), + ), + ).toBe(true); + expect(fs.existsSync(path.join(npmRoot, "node_modules", "next-plugin"))).toBe(true); + expect(fs.readFileSync(staleNodeModulesPath, "utf-8")).toBe("not a directory"); + }, + ); + it("rejects managed npm plugins when their openclaw peer link cannot be repaired", async () => { const stateDir = suiteTempRootTracker.makeTempDir(); const npmRoot = path.join(stateDir, "npm"); diff --git a/src/plugins/install.test.ts b/src/plugins/install.test.ts index 3db289ffe95..c401269bda4 100644 --- a/src/plugins/install.test.ts +++ b/src/plugins/install.test.ts @@ -3061,7 +3061,7 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { expect(fs.lstatSync(symlinkPath).isSymbolicLink()).toBe(true); }); - it("warns and skips when resolveOpenClawPackageRootSync returns null", async () => { + it("rejects when resolveOpenClawPackageRootSync returns null", async () => { const { pluginDir, extensionsDir } = setupPluginInstallDirs(); resolveRootMock.mockReturnValue(null); @@ -3069,7 +3069,10 @@ describe("linkOpenClawPeerDependencies (via installPluginFromDir)", () => { const { result, warnings } = await installFromDirWithWarnings({ pluginDir, extensionsDir }); - expect(result.ok).toBe(true); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("plugin-local node_modules/openclaw link"); + } expectWarningIncludes(warnings, "Could not locate openclaw package root"); }); }); diff --git a/src/plugins/install.ts b/src/plugins/install.ts index 4e78adc1871..48023c9e66b 100644 --- a/src/plugins/install.ts +++ b/src/plugins/install.ts @@ -537,7 +537,10 @@ async function installPluginFromManagedNpmRoot( } } try { - await relinkOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot, logger }); + await relinkOpenClawPeerDependenciesInManagedNpmRoot({ + npmRoot, + logger, + }); } catch (error) { await rollbackManagedNpmPluginInstall({ npmRoot, @@ -1213,11 +1216,17 @@ async function scanAndLinkInstalledPackage(params: { if (scanResult) { return scanResult; } - await linkOpenClawPeerDependencies({ + const peerLinkRepair = await linkOpenClawPeerDependencies({ installedDir: params.installedDir, peerDependencies: params.peerDependencies, logger: params.logger, }); + if (peerLinkRepair.skipped > 0) { + return { + ok: false, + error: formatUnresolvedOpenClawPeerLinkError(params.pluginId), + }; + } return null; } diff --git a/src/plugins/plugin-peer-link.test.ts b/src/plugins/plugin-peer-link.test.ts new file mode 100644 index 00000000000..206ac8b4879 --- /dev/null +++ b/src/plugins/plugin-peer-link.test.ts @@ -0,0 +1,106 @@ +import fs from "node:fs"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + auditOpenClawPeerDependenciesInManagedNpmRoot, + linkOpenClawPeerDependencies, + relinkOpenClawPeerDependenciesInManagedNpmRoot, +} from "./plugin-peer-link.js"; +import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; + +const tempDirs: string[] = []; + +afterEach(() => { + cleanupTrackedTempDirs(tempDirs); +}); + +function makeTempDir() { + return makeTrackedTempDir("openclaw-plugin-peer-link", tempDirs); +} + +describe("plugin peer links", () => { + it("relinks openclaw peers in the managed npm root", async () => { + const npmRoot = makeTempDir(); + const packageDir = path.join(npmRoot, "node_modules", "peer-plugin"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "peer-plugin", + version: "1.0.0", + peerDependencies: { + openclaw: ">=2026.0.0", + }, + }), + "utf8", + ); + + const messages: string[] = []; + const result = await relinkOpenClawPeerDependenciesInManagedNpmRoot({ + npmRoot, + logger: { + info: (message) => messages.push(message), + warn: (message) => messages.push(message), + }, + }); + + const linkPath = path.join(packageDir, "node_modules", "openclaw"); + expect(result).toEqual({ checked: 1, attempted: 1, repaired: 1, skipped: 0 }); + expect(fs.lstatSync(linkPath).isSymbolicLink()).toBe(true); + expect(fs.realpathSync(linkPath)).toBe(fs.realpathSync(process.cwd())); + expect(messages.join("\n")).toContain('Linked peerDependency "openclaw"'); + }); + + it("audits missing managed npm openclaw peer links without relinking", async () => { + const npmRoot = makeTempDir(); + const packageDir = path.join(npmRoot, "node_modules", "peer-plugin"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "peer-plugin", + version: "1.0.0", + peerDependencies: { + openclaw: ">=2026.0.0", + }, + }), + "utf8", + ); + + const result = await auditOpenClawPeerDependenciesInManagedNpmRoot({ npmRoot }); + + const linkPath = path.join(packageDir, "node_modules", "openclaw"); + expect(result.checked).toBe(1); + expect(result.broken).toBe(1); + expect(result.issues[0]?.packageName).toBe("peer-plugin"); + expect(result.issues[0]?.reason).toContain(linkPath); + expect(fs.existsSync(linkPath)).toBe(false); + }); + + it.runIf(process.platform !== "win32")( + "does not follow a package-local node_modules symlink while linking openclaw peers", + async () => { + const root = makeTempDir(); + const packageDir = path.join(root, "peer-plugin"); + const outsideDir = path.join(root, "outside-node-modules"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, path.join(packageDir, "node_modules"), "dir"); + + const warnings: string[] = []; + const result = await linkOpenClawPeerDependencies({ + installedDir: packageDir, + peerDependencies: { + openclaw: ">=2026.0.0", + }, + logger: { + warn: (message) => warnings.push(message), + }, + }); + + expect(result).toEqual({ repaired: 0, skipped: 1 }); + expect(fs.existsSync(path.join(outsideDir, "openclaw"))).toBe(false); + expect(warnings.join("\n")).toContain("is not a real directory"); + }, + ); +}); diff --git a/src/plugins/plugin-peer-link.ts b/src/plugins/plugin-peer-link.ts index 01ebad64776..2f3fba3a50b 100644 --- a/src/plugins/plugin-peer-link.ts +++ b/src/plugins/plugin-peer-link.ts @@ -10,8 +10,24 @@ type PluginPeerLinkLogger = { type RelinkManagedNpmRootResult = { checked: number; attempted: number; + repaired: number; + skipped: number; }; +type OpenClawPeerLinkAuditIssue = { + packageName: string; + packageDir: string; + reason: string; +}; + +type AuditManagedNpmRootResult = { + checked: number; + broken: number; + issues: OpenClawPeerLinkAuditIssue[]; +}; + +type OpenClawPeerLinkResult = "linked" | "skipped" | "unchanged"; + function readStringRecord(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; @@ -77,6 +93,134 @@ async function listManagedNpmRootPackageDirs(npmRoot: string): Promise return packageDirs.toSorted((a, b) => a.localeCompare(b)); } +async function safeRealpath(filePath: string): Promise { + try { + return await fs.realpath(filePath); + } catch { + return null; + } +} + +function managedPackageNameFromDir(params: { npmRoot: string; packageDir: string }): string { + return path + .relative(path.join(params.npmRoot, "node_modules"), params.packageDir) + .split(path.sep) + .join("/"); +} + +async function auditOpenClawPeerDependency(params: { + hostRoot: string; + npmRoot: string; + packageDir: string; +}): Promise { + const packageName = managedPackageNameFromDir({ + npmRoot: params.npmRoot, + packageDir: params.packageDir, + }); + const nodeModulesDir = path.join(params.packageDir, "node_modules"); + try { + const existing = await fs.lstat(nodeModulesDir); + if (!existing.isDirectory() || existing.isSymbolicLink()) { + return { + packageName, + packageDir: params.packageDir, + reason: `${nodeModulesDir} is not a real directory`, + }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return { + packageName, + packageDir: params.packageDir, + reason: `missing ${path.join(nodeModulesDir, "openclaw")}`, + }; + } + throw error; + } + + const linkPath = path.join(nodeModulesDir, "openclaw"); + const currentTarget = await safeRealpath(linkPath); + if (!currentTarget) { + return { + packageName, + packageDir: params.packageDir, + reason: `missing ${linkPath}`, + }; + } + const expectedTarget = (await safeRealpath(params.hostRoot)) ?? params.hostRoot; + if (currentTarget !== expectedTarget) { + return { + packageName, + packageDir: params.packageDir, + reason: `${linkPath} points to ${currentTarget} instead of ${expectedTarget}`, + }; + } + return null; +} + +async function ensureRealNodeModulesDir(params: { + installedDir: string; + logger: PluginPeerLinkLogger; +}): Promise { + const nodeModulesDir = path.join(params.installedDir, "node_modules"); + try { + const existing = await fs.lstat(nodeModulesDir); + if (!existing.isDirectory() || existing.isSymbolicLink()) { + params.logger.warn?.( + `Skipping openclaw peerDependency link because ${nodeModulesDir} is not a real directory.`, + ); + return null; + } + return nodeModulesDir; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + await fs.mkdir(nodeModulesDir, { recursive: true }); + const created = await fs.lstat(nodeModulesDir); + if (!created.isDirectory() || created.isSymbolicLink()) { + params.logger.warn?.( + `Skipping openclaw peerDependency link because ${nodeModulesDir} is not a real directory.`, + ); + return null; + } + return nodeModulesDir; +} + +async function linkOpenClawPeerDependency(params: { + hostRoot: string; + installedDir: string; + peerName: string; + logger: PluginPeerLinkLogger; +}): Promise { + const nodeModulesDir = await ensureRealNodeModulesDir({ + installedDir: params.installedDir, + logger: params.logger, + }); + if (!nodeModulesDir) { + return "skipped"; + } + + const linkPath = path.join(nodeModulesDir, params.peerName); + const expectedTarget = (await safeRealpath(params.hostRoot)) ?? params.hostRoot; + const currentTarget = await safeRealpath(linkPath); + if (currentTarget === expectedTarget) { + return "unchanged"; + } + + try { + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(params.hostRoot, linkPath, "junction"); + params.logger.info?.(`Linked peerDependency "${params.peerName}" -> ${params.hostRoot}`); + return "linked"; + } catch (err) { + params.logger.warn?.(`Failed to symlink peerDependency "${params.peerName}": ${String(err)}`); + return "skipped"; + } +} + /** * Symlink the host openclaw package for plugins that declare it as a peer. * Plugin package managers still own third-party dependencies; this only wires @@ -86,10 +230,10 @@ export async function linkOpenClawPeerDependencies(params: { installedDir: string; peerDependencies: Record; logger: PluginPeerLinkLogger; -}): Promise { +}): Promise<{ repaired: number; skipped: number }> { const peers = Object.keys(params.peerDependencies).filter((name) => name === "openclaw"); if (peers.length === 0) { - return; + return { repaired: 0, skipped: 0 }; } const hostRoot = resolveOpenClawPackageRootSync({ @@ -101,23 +245,25 @@ export async function linkOpenClawPeerDependencies(params: { params.logger.warn?.( "Could not locate openclaw package root to symlink peerDependencies; plugin may fail to resolve openclaw at runtime.", ); - return; + return { repaired: 0, skipped: peers.length }; } - const nodeModulesDir = path.join(params.installedDir, "node_modules"); - await fs.mkdir(nodeModulesDir, { recursive: true }); - + let repaired = 0; + let skipped = 0; for (const peerName of peers) { - const linkPath = path.join(nodeModulesDir, peerName); - - try { - await fs.rm(linkPath, { recursive: true, force: true }); - await fs.symlink(hostRoot, linkPath, "junction"); - params.logger.info?.(`Linked peerDependency "${peerName}" -> ${hostRoot}`); - } catch (err) { - params.logger.warn?.(`Failed to symlink peerDependency "${peerName}": ${String(err)}`); + const result = await linkOpenClawPeerDependency({ + hostRoot, + installedDir: params.installedDir, + peerName, + logger: params.logger, + }); + if (result === "linked") { + repaired += 1; + } else if (result === "skipped") { + skipped += 1; } } + return { repaired, skipped }; } export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: { @@ -126,18 +272,54 @@ export async function relinkOpenClawPeerDependenciesInManagedNpmRoot(params: { }): Promise { let checked = 0; let attempted = 0; + let repaired = 0; + let skipped = 0; for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) { const peerDependencies = await readPackagePeerDependencies(packageDir); if (!Object.hasOwn(peerDependencies, "openclaw")) { continue; } checked += 1; - await linkOpenClawPeerDependencies({ + const result = await linkOpenClawPeerDependencies({ installedDir: packageDir, peerDependencies, logger: params.logger, }); attempted += 1; + repaired += result.repaired; + skipped += result.skipped; } - return { checked, attempted }; + return { checked, attempted, repaired, skipped }; +} + +export async function auditOpenClawPeerDependenciesInManagedNpmRoot(params: { + npmRoot: string; +}): Promise { + const hostRoot = resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + moduleUrl: import.meta.url, + cwd: process.cwd(), + }); + if (!hostRoot) { + return { checked: 0, broken: 0, issues: [] }; + } + + let checked = 0; + const issues: OpenClawPeerLinkAuditIssue[] = []; + for (const packageDir of await listManagedNpmRootPackageDirs(params.npmRoot)) { + const peerDependencies = await readPackagePeerDependencies(packageDir); + if (!Object.hasOwn(peerDependencies, "openclaw")) { + continue; + } + checked += 1; + const issue = await auditOpenClawPeerDependency({ + hostRoot, + npmRoot: params.npmRoot, + packageDir, + }); + if (issue) { + issues.push(issue); + } + } + return { checked, broken: issues.length, issues }; } diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 1a5679a9a8b..b109a1ad9b1 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -1108,7 +1108,7 @@ describe("updateNpmInstalledPlugins", () => { expect(fs.existsSync(peerLinkPath("brave"))).toBe(true); expect(fs.existsSync(peerLinkPath("codex"))).toBe(true); expect(warnMessages).toEqual([ - `Could not repair openclaw peer link for "broken" at ${brokenInstallPath}: Error: EEXIST: file already exists, mkdir '${path.join(brokenInstallPath, "node_modules")}'`, + `Could not repair openclaw peer link for "broken" at ${brokenInstallPath}: Skipping openclaw peerDependency link because ${path.join(brokenInstallPath, "node_modules")} is not a real directory.`, ]); }); diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 87bfe283ce0..69cba96c4df 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -809,11 +809,21 @@ async function repairOpenClawPeerLinksForNpmInstalls(params: { } try { - await linkOpenClawPeerDependencies({ + const warnings: string[] = []; + const peerLinkRepair = await linkOpenClawPeerDependencies({ installedDir: installPath, peerDependencies, - logger: params.logger, + logger: { + info: (message) => params.logger.info?.(message), + warn: (message) => warnings.push(message), + }, }); + if (peerLinkRepair.skipped > 0) { + params.logger.warn?.( + `Could not repair openclaw peer link for "${pluginId}" at ${installPath}: ${warnings.join("; ") || "peer link repair was skipped"}`, + ); + continue; + } repaired = !installedPackageNeedsOpenClawPeerLinkRepair(installPath) || repaired; } catch (err) { params.logger.warn?.(