diff --git a/extensions/qa-lab/src/bundled-plugin-staging.ts b/extensions/qa-lab/src/bundled-plugin-staging.ts index 27d33c82757..a605a2771ef 100644 --- a/extensions/qa-lab/src/bundled-plugin-staging.ts +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; @@ -76,16 +76,20 @@ export function resolveQaBundledPluginSourceDir(params: { repoRoot: string; plug path.join(params.repoRoot, "extensions", params.pluginId), ]; const existingCandidates = candidates.filter((candidate) => existsSync(candidate)); - if (existingCandidates.length === 0) { + const manifestCandidates = findQaBundledPluginDirsByManifestId(params); + const allCandidates = [...existingCandidates, ...manifestCandidates].filter( + (candidate, index, all) => all.indexOf(candidate) === index, + ); + if (allCandidates.length === 0) { return null; } - const cliMetadataCandidate = existingCandidates.find((candidate) => + const cliMetadataCandidate = allCandidates.find((candidate) => QA_CLI_METADATA_ENTRY_BASENAMES.some((basename) => existsSync(path.join(candidate, basename))), ); if (cliMetadataCandidate) { return cliMetadataCandidate; } - return existingCandidates[0] ?? null; + return allCandidates[0] ?? null; } function resolveQaBundledPluginScanRoots(repoRoot: string) { @@ -96,6 +100,37 @@ function resolveQaBundledPluginScanRoots(repoRoot: string) { ].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index); } +function readQaBundledManifestId(manifestPath: string): string | null { + try { + const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as { id?: unknown }; + return typeof parsed.id === "string" ? parsed.id.trim() || null : null; + } catch { + return null; + } +} + +function findQaBundledPluginDirsByManifestId(params: { + repoRoot: string; + pluginId: string; +}): string[] { + const candidates: string[] = []; + for (const sourceRoot of resolveQaBundledPluginScanRoots(params.repoRoot)) { + for (const entry of readdirSync(sourceRoot, { withFileTypes: true }).toSorted((left, right) => + left.name.localeCompare(right.name), + )) { + if (!entry.isDirectory()) { + continue; + } + const candidate = path.join(sourceRoot, entry.name); + const manifestId = readQaBundledManifestId(path.join(candidate, "openclaw.plugin.json")); + if (manifestId === params.pluginId) { + candidates.push(candidate); + } + } + } + return candidates; +} + export async function resolveQaOwnerPluginIdsForProviderIds(params: { repoRoot: string; providerIds: readonly string[]; diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index e250ec5970f..3261dde31f7 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -791,6 +791,33 @@ describe("qa bundled plugin dir", () => { ).toBe(path.join(repoRoot, "extensions", "qa-channel")); }); + it("resolves bundled plugins by manifest id when the directory name differs", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-manifest-id-root-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await mkdir(path.join(repoRoot, "dist", "extensions", "kimi-coding"), { + recursive: true, + }); + await writeFile( + path.join(repoRoot, "dist", "extensions", "kimi-coding", "openclaw.plugin.json"), + JSON.stringify({ id: "kimi", providers: ["kimi"] }), + "utf8", + ); + await writeFile( + path.join(repoRoot, "dist", "extensions", "kimi-coding", "package.json"), + "{}", + "utf8", + ); + + expect( + __testing.resolveQaBundledPluginSourceDir({ + repoRoot, + pluginId: "kimi", + }), + ).toBe(path.join(repoRoot, "dist", "extensions", "kimi-coding")); + }); + it("uses a source bundled plugin when the built copy is missing CLI metadata", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-cli-metadata-root-")); cleanups.push(async () => { diff --git a/scripts/lib/plugin-gateway-gauntlet.mjs b/scripts/lib/plugin-gateway-gauntlet.mjs index 7659a562ff4..2910aeb1749 100644 --- a/scripts/lib/plugin-gateway-gauntlet.mjs +++ b/scripts/lib/plugin-gateway-gauntlet.mjs @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import JSON5 from "json5"; +import { collectBundledPluginBuildEntries } from "./bundled-plugin-build-entries.mjs"; const MANIFEST_NAMES = ["openclaw.plugin.json", "openclaw.plugin.json5"]; @@ -142,9 +143,13 @@ function buildPluginMatrixEntry(params) { function discoverBundledPluginManifests(repoRoot) { const extensionsDir = path.join(repoRoot, "extensions"); + const buildEntryDirs = new Set( + collectBundledPluginBuildEntries({ cwd: repoRoot }).map((entry) => entry.id), + ); const entries = fs .readdirSync(extensionsDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) + .filter((entry) => buildEntryDirs.has(entry.name)) .flatMap((entry) => { const pluginDir = path.join(extensionsDir, entry.name); const manifestName = MANIFEST_NAMES.find((name) => fs.existsSync(path.join(pluginDir, name))); diff --git a/test/scripts/plugin-gateway-gauntlet.test.ts b/test/scripts/plugin-gateway-gauntlet.test.ts index f241b964d62..95dd008625a 100644 --- a/test/scripts/plugin-gateway-gauntlet.test.ts +++ b/test/scripts/plugin-gateway-gauntlet.test.ts @@ -53,8 +53,8 @@ describe("plugin gateway gauntlet helpers", () => { ); await writeManifest( "beta", - "openclaw.plugin.json5", - `{ id: "beta", commandAliases: ["dreaming"], onboardingScopes: ["memory"] }`, + "openclaw.plugin.json", + JSON.stringify({ id: "beta", commandAliases: ["dreaming"], onboardingScopes: ["memory"] }), ); const matrix = discoverBundledPluginManifests(repoRoot); @@ -77,6 +77,15 @@ describe("plugin gateway gauntlet helpers", () => { ]); }); + it("skips source-only plugin dirs that are excluded from the built runtime", async () => { + await writeManifest("qqbot", "openclaw.plugin.json", JSON.stringify({ id: "qqbot" })); + await writeManifest("telegram", "openclaw.plugin.json", JSON.stringify({ id: "telegram" })); + + const matrix = discoverBundledPluginManifests(repoRoot); + + expect(matrix.map((entry) => entry.id)).toEqual(["telegram"]); + }); + it("selects plugin shards after explicit id filtering", () => { const entries = ["a", "b", "c", "d"].map((id) => ({ id }));