diff --git a/src/plugins/bundled-channel-runtime.test.ts b/src/plugins/bundled-channel-runtime.test.ts new file mode 100644 index 00000000000..4526162a317 --- /dev/null +++ b/src/plugins/bundled-channel-runtime.test.ts @@ -0,0 +1,42 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + listBundledChannelPluginMetadata, + resolveBundledChannelWorkspacePath, +} from "./bundled-channel-runtime.js"; + +const tempRoots: string[] = []; + +function createTempRoot(): string { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-empty-bundled-root-")); + tempRoots.push(tempRoot); + return tempRoot; +} + +afterEach(() => { + for (const tempRoot of tempRoots.splice(0)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +describe("bundled channel runtime metadata", () => { + it("preserves explicit empty bundled roots", () => { + const tempRoot = createTempRoot(); + + expect(listBundledChannelPluginMetadata({ rootDir: tempRoot })).toEqual([]); + expect(resolveBundledChannelWorkspacePath({ rootDir: tempRoot, pluginId: "telegram" })).toBe( + null, + ); + }); + + it("preserves explicit missing bundled scan roots", () => { + const tempRoot = createTempRoot(); + const missingScanDir = path.join(tempRoot, "missing-extensions"); + + expect( + listBundledChannelPluginMetadata({ rootDir: tempRoot, scanDir: missingScanDir }), + ).toEqual([]); + }); +}); diff --git a/src/plugins/bundled-channel-runtime.ts b/src/plugins/bundled-channel-runtime.ts index 6f43fb8bff8..d9190b69ea2 100644 --- a/src/plugins/bundled-channel-runtime.ts +++ b/src/plugins/bundled-channel-runtime.ts @@ -10,6 +10,11 @@ type BundledChannelEntryPathPair = { built: string; }; +type BundledMetadataScope = + | { kind: "default" } + | { kind: "empty" } + | { kind: "env"; env: NodeJS.ProcessEnv }; + export type BundledChannelPluginMetadata = { dirName: string; source: BundledChannelEntryPathPair; @@ -22,22 +27,28 @@ export type BundledChannelPluginMetadata = { rootDir: string; }; -function resolveBundledMetadataEnv(params?: { +function resolveBundledMetadataScope(params?: { rootDir?: string; scanDir?: string; -}): NodeJS.ProcessEnv | undefined { +}): BundledMetadataScope { const overrideDir = params?.scanDir ? path.resolve(params.scanDir) : params?.rootDir ? resolveBundledPluginsDirForRoot(params.rootDir) : undefined; if (!overrideDir) { - return undefined; + return params?.rootDir ? { kind: "empty" } : { kind: "default" }; + } + if (!fs.existsSync(overrideDir)) { + return { kind: "empty" }; } return { - ...process.env, - OPENCLAW_BUNDLED_PLUGINS_DIR: overrideDir, - OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + kind: "env", + env: { + ...process.env, + OPENCLAW_BUNDLED_PLUGINS_DIR: overrideDir, + OPENCLAW_TEST_TRUST_BUNDLED_PLUGINS_DIR: "1", + }, }; } @@ -87,8 +98,12 @@ export function listBundledChannelPluginMetadata(params?: { includeChannelConfigs?: boolean; includeSyntheticChannelConfigs?: boolean; }): readonly BundledChannelPluginMetadata[] { + const scope = resolveBundledMetadataScope(params); + if (scope.kind === "empty") { + return []; + } return loadPluginManifestRegistryForPluginRegistry({ - env: resolveBundledMetadataEnv(params), + env: scope.kind === "env" ? scope.env : undefined, includeDisabled: true, }).plugins.flatMap((record) => toBundledChannelPluginMetadata(record) ?? []); } diff --git a/src/plugins/config-state.test.ts b/src/plugins/config-state.test.ts index afce22dc940..1e85d69bd7a 100644 --- a/src/plugins/config-state.test.ts +++ b/src/plugins/config-state.test.ts @@ -178,6 +178,55 @@ describe("normalizePluginsConfig", () => { expect(result.entries["unknown-plugin-four"]?.enabled).toBe(true); expect(discoverPlugins).toHaveBeenCalledTimes(1); }); + + it("keeps alias lookup limited to bundled plugin manifests", async () => { + vi.resetModules(); + const discovery = await import("./discovery.js"); + const manifest = await import("./manifest.js"); + const discoverPlugins = vi.spyOn(discovery, "discoverOpenClawPlugins").mockReturnValue({ + candidates: [ + { + idHint: "anthropic", + source: "/tmp/openclaw-bundled-anthropic/index.js", + rootDir: "/tmp/openclaw-bundled-anthropic", + origin: "bundled", + bundledManifest: { + id: "anthropic", + configSchema: {}, + providers: ["anthropic"], + }, + }, + { + idHint: "external-anthropic", + source: "/tmp/openclaw-global-anthropic/index.js", + rootDir: "/tmp/openclaw-global-anthropic", + origin: "global", + }, + ], + diagnostics: [], + }); + const loadManifest = vi.spyOn(manifest, "loadPluginManifest").mockReturnValue({ + ok: true, + manifestPath: "/tmp/openclaw-global-anthropic/openclaw.plugin.json", + manifest: { + id: "external-anthropic", + configSchema: {}, + providers: ["anthropic"], + }, + }); + const { normalizePluginsConfig: normalizeFreshPluginsConfig } = + await import("./config-state.js"); + discoverPlugins.mockClear(); + loadManifest.mockClear(); + + const result = normalizeFreshPluginsConfig({ + deny: ["anthropic"], + }); + + expect(result.deny).toEqual(["anthropic"]); + expect(discoverPlugins).toHaveBeenCalledTimes(1); + expect(loadManifest).not.toHaveBeenCalled(); + }); }); describe("resolveEffectiveEnableState", () => { diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 614fda6d2e5..5be3ad920ce 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -49,10 +49,12 @@ const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map([ function getBundledPluginAliasLookup(): ReadonlyMap { const lookup = new Map(); for (const candidate of discoverOpenClawPlugins({}).candidates) { - const manifestResult = - candidate.origin === "bundled" && candidate.bundledManifest - ? { ok: true as const, manifest: candidate.bundledManifest } - : loadPluginManifest(candidate.rootDir, candidate.origin !== "bundled"); + if (candidate.origin !== "bundled") { + continue; + } + const manifestResult = candidate.bundledManifest + ? { ok: true as const, manifest: candidate.bundledManifest } + : loadPluginManifest(candidate.rootDir, false); if (!manifestResult.ok) { continue; }