diff --git a/scripts/clawdock/README.md b/scripts/clawdock/README.md index 90724ec0aa2..05ede8ed225 100644 --- a/scripts/clawdock/README.md +++ b/scripts/clawdock/README.md @@ -202,7 +202,11 @@ This means: - `~/.openclaw/.env` is available inside the container at `/home/node/.openclaw/.env` — OpenClaw loads it automatically as the global env fallback - `~/.openclaw/openclaw.json` is available at `/home/node/.openclaw/openclaw.json` — the gateway watches it and hot-reloads most changes - `~/.openclaw-auth-profile-secrets` is available at `/home/node/.config/openclaw` — OpenClaw stores the auth-profile encryption key there -- Downloadable plugin packages and install records live under the mounted OpenClaw home +- Downloadable external plugin packages and install records live under the mounted OpenClaw home +- Bundled OpenClaw channel plugins, such as Discord when present in the image, + should normally load from the image-matched bundled copy. Avoid installing + pinned `@openclaw/*` channel packages into the mounted home unless you + deliberately want an external npm override. - No need to add API keys to `docker-compose.yml` or configure anything inside the container - Keys survive `clawdock-update`, `clawdock-rebuild`, and `clawdock-clean` because they live on the host diff --git a/src/cli/plugin-install-plan.test.ts b/src/cli/plugin-install-plan.test.ts index ed5a863afc3..5cf2dba83bf 100644 --- a/src/cli/plugin-install-plan.test.ts +++ b/src/cli/plugin-install-plan.test.ts @@ -27,14 +27,44 @@ describe("plugin install plan helpers", () => { expect(result?.warning).toContain('bare install spec "voice-call"'); }); - it("skips bundled pre-plan for scoped npm specs", () => { - const findBundledSource = vi.fn(); + it("prefers bundled plugin for scoped npm package specs", () => { + const findBundledSource = vi + .fn() + .mockImplementation(({ kind, value }: { kind: "pluginId" | "npmSpec"; value: string }) => { + if (kind === "npmSpec" && value === "@openclaw/voice-call") { + return { + pluginId: "voice-call", + localPath: installedPluginRoot("/tmp", "voice-call"), + npmSpec: "@openclaw/voice-call", + }; + } + return undefined; + }); const result = resolveBundledInstallPlanBeforeNpm({ - rawSpec: "@openclaw/voice-call", + rawSpec: "@openclaw/voice-call@2026.5.20", + findBundledSource, + }); + + expect(findBundledSource).toHaveBeenCalledWith({ + kind: "npmSpec", + value: "@openclaw/voice-call@2026.5.20", + }); + expect(findBundledSource).toHaveBeenCalledWith({ + kind: "npmSpec", + value: "@openclaw/voice-call", + }); + expect(result?.bundledSource.pluginId).toBe("voice-call"); + expect(result?.warning).toContain('npm install spec "@openclaw/voice-call@2026.5.20"'); + expect(result?.warning).toContain("npm:@openclaw/voice-call@2026.5.20"); + }); + + it("skips bundled pre-plan for npm specs that do not match bundled packages", () => { + const findBundledSource = vi.fn(); + const result = resolveBundledInstallPlanBeforeNpm({ + rawSpec: "@openclaw/not-bundled", findBundledSource, }); - expect(findBundledSource).not.toHaveBeenCalled(); expect(result).toBeNull(); }); diff --git a/src/cli/plugin-install-plan.ts b/src/cli/plugin-install-plan.ts index 1829bf1d981..8e244f29bd1 100644 --- a/src/cli/plugin-install-plan.ts +++ b/src/cli/plugin-install-plan.ts @@ -66,19 +66,43 @@ export function resolveBundledInstallPlanBeforeNpm(params: { rawSpec: string; findBundledSource: BundledLookup; }): { bundledSource: BundledPluginSource; warning: string } | null { - if (!isBareNpmPackageName(params.rawSpec)) { + const rawSpec = params.rawSpec.trim(); + if (!rawSpec) { return null; } - const bundledSource = params.findBundledSource({ - kind: "pluginId", - value: params.rawSpec, - }); + if (isBareNpmPackageName(rawSpec)) { + const bundledSource = params.findBundledSource({ + kind: "pluginId", + value: rawSpec, + }); + if (!bundledSource) { + return null; + } + return { + bundledSource, + warning: `Using bundled plugin "${bundledSource.pluginId}" from ${shortenHomePath(bundledSource.localPath)} for bare install spec "${rawSpec}". To install an npm package with the same name, use a scoped package name (for example @scope/${rawSpec}).`, + }; + } + + const parsedNpmSpec = parseRegistryNpmSpec(rawSpec); + if (!parsedNpmSpec) { + return null; + } + const bundledSource = + params.findBundledSource({ + kind: "npmSpec", + value: rawSpec, + }) ?? + params.findBundledSource({ + kind: "npmSpec", + value: parsedNpmSpec.name, + }); if (!bundledSource) { return null; } return { bundledSource, - warning: `Using bundled plugin "${bundledSource.pluginId}" from ${shortenHomePath(bundledSource.localPath)} for bare install spec "${params.rawSpec}". To install an npm package with the same name, use a scoped package name (for example @scope/${params.rawSpec}).`, + warning: `Using bundled plugin "${bundledSource.pluginId}" from ${shortenHomePath(bundledSource.localPath)} for npm install spec "${rawSpec}" because this plugin ships with the current OpenClaw build. To force an external npm override, use npm:${rawSpec}.`, }; } diff --git a/src/cli/plugins-cli.install.test.ts b/src/cli/plugins-cli.install.test.ts index a87a23cf021..dd6148365dd 100644 --- a/src/cli/plugins-cli.install.test.ts +++ b/src/cli/plugins-cli.install.test.ts @@ -1054,6 +1054,7 @@ describe("plugins cli install", () => { const enabledCfg = createEnabledPluginConfig("discord"); loadConfig.mockReturnValue(cfg); + findBundledPluginSourceMock.mockReturnValue(undefined); installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("discord")); enablePluginInConfig.mockReturnValue({ config: enabledCfg }); recordPluginInstall.mockReturnValue(enabledCfg); @@ -1070,11 +1071,60 @@ describe("plugins cli install", () => { expect(installPluginFromClawHub).not.toHaveBeenCalled(); }); + it("uses bundled OpenClaw package specs instead of pinning stale managed npm overrides", async () => { + const cfg = createEmptyPluginConfig(); + const enabledCfg = createEnabledPluginConfig("discord"); + const bundledPath = "/app/dist/extensions/discord"; + + loadConfig.mockReturnValue(cfg); + findBundledPluginSourceMock.mockImplementation( + ({ lookup }: { lookup: { kind: "pluginId" | "npmSpec"; value: string } }) => + lookup.kind === "npmSpec" && lookup.value === "@openclaw/discord" + ? { + pluginId: "discord", + localPath: bundledPath, + npmSpec: "@openclaw/discord", + version: "2026.5.24-beta.2", + } + : undefined, + ); + enablePluginInConfig.mockReturnValue({ config: enabledCfg }); + recordPluginInstall.mockReturnValue(enabledCfg); + applyExclusiveSlotSelection.mockReturnValue({ + config: enabledCfg, + warnings: [], + }); + + await runPluginsCommand([ + "plugins", + "install", + "@openclaw/discord@2026.5.20", + "--pin", + "--force", + ]); + + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ + lookup: { kind: "npmSpec", value: "@openclaw/discord@2026.5.20" }, + }); + expect(findBundledPluginSourceMock).toHaveBeenCalledWith({ + lookup: { kind: "npmSpec", value: "@openclaw/discord" }, + }); + const record = persistedInstallRecord("discord"); + expect(record.source).toBe("path"); + expect(record.spec).toBe("@openclaw/discord@2026.5.20"); + expect(record.sourcePath).toBe(bundledPath); + expect(record.installPath).toBe(bundledPath); + expect(runtimeLogsContain("ships with the current OpenClaw build")).toBe(true); + expect(runtimeLogsContain("npm:@openclaw/discord@2026.5.20")).toBe(true); + }); + it("marks catalog npm package installs with alternate selectors as trusted", async () => { const cfg = createEmptyPluginConfig(); const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin"); loadConfig.mockReturnValue(cfg); + findBundledPluginSourceMock.mockReturnValue(undefined); installPluginFromNpmSpec.mockResolvedValue( createNpmPluginInstallResult("wecom-openclaw-plugin"), );