fix(doctor): repair managed plugin peer links

Repair managed npm plugin OpenClaw peer links across doctor, install, and update flows.

- relink `peerDependencies.openclaw` packages under managed npm roots during doctor repair
- make read-only doctor preview broken peer links with a `doctor --fix` hint
- reject target plugin installs when their own peer link cannot be repaired, without blocking unrelated installs for stale sibling packages
- preserve update warning behavior for unrepairable package-local `node_modules`

Verification:
- `pnpm test src/plugins/plugin-peer-link.test.ts src/plugins/install.test.ts src/plugins/install.npm-spec.test.ts src/plugins/update.test.ts src/commands/doctor-plugin-registry.test.ts src/commands/doctor/repair-sequencing.test.ts -- --reporter=verbose`
- `pnpm exec oxfmt --check --threads=1 ...`
- `git diff --check`
- Crabbox/Testbox `tbx_01krde1jx199rnpm2rv1rdcj76`: focused tests + `pnpm check:changed`, exit 0
- Real CLI proof in PR body: read-only `openclaw doctor` warning plus `openclaw doctor --fix` symlink repair

Thanks @TheCrazyLex.
This commit is contained in:
Alex Naidis
2026-05-12 08:49:08 +02:00
committed by GitHub
parent ee1a278aea
commit a290cd633f
15 changed files with 568 additions and 34 deletions

View File

@@ -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.

View File

@@ -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.<id>` 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.

View File

@@ -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.
<Warning>
`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.

View File

@@ -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.
</Accordion>
<Accordion title="7b. Plugin install cleanup">
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.

View File

@@ -114,6 +114,7 @@ function createManagedNpmPlugin(params: {
id: string;
packageName: string;
version: string;
peerDependencies?: Record<string, string>;
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);
});
});

View File

@@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -57,6 +66,12 @@ function readStringMap(value: unknown): Record<string, string> {
return result;
}
function resolveManagedPluginNpmRoot(params: PluginRegistryDoctorRepairParams): string {
return params.stateDir
? path.join(params.stateDir, "npm")
: resolveDefaultPluginNpmDir(params.env);
}
function deleteObjectKey(record: Record<string, unknown>, 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<boolean> {
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",

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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");

View File

@@ -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");
});
});

View File

@@ -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;
}

View File

@@ -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");
},
);
});

View File

@@ -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<string, string> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
@@ -77,6 +93,134 @@ async function listManagedNpmRootPackageDirs(npmRoot: string): Promise<string[]>
return packageDirs.toSorted((a, b) => a.localeCompare(b));
}
async function safeRealpath(filePath: string): Promise<string | null> {
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<OpenClawPeerLinkAuditIssue | null> {
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<string | null> {
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<OpenClawPeerLinkResult> {
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<string, string>;
logger: PluginPeerLinkLogger;
}): Promise<void> {
}): 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<RelinkManagedNpmRootResult> {
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<AuditManagedNpmRootResult> {
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 };
}

View File

@@ -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.`,
]);
});

View File

@@ -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?.(