mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 23:56:07 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
106
src/plugins/plugin-peer-link.test.ts
Normal file
106
src/plugins/plugin-peer-link.test.ts
Normal 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");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.`,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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?.(
|
||||
|
||||
Reference in New Issue
Block a user