diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cfe7c4718..1dc12141dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai - Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF. - Dreaming/memory-core: change the default `dreaming.storage.mode` from `inline` to `separate` so Dreaming phase blocks (`## Light Sleep`, `## REM Sleep`) land in `memory/dreaming/{phase}/YYYY-MM-DD.md` instead of being injected into `memory/YYYY-MM-DD.md`. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting `plugins.entries.memory-core.config.dreaming.storage.mode: "inline"`. (#66412) Thanks @mjamiv. - Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine. +- QA/private runtime: keep the private QA CLI on the local plugin-sdk seam and preserve staged `dist-runtime` root chunks when isolated QA staging mixes built plugin trees. (#67428) thanks @gumadeiras. ## 2026.4.15-beta.1 diff --git a/extensions/qa-channel/src/runtime.ts b/extensions/qa-channel/src/runtime.ts index 353e2dcc29c..10196a8af24 100644 --- a/extensions/qa-channel/src/runtime.ts +++ b/extensions/qa-channel/src/runtime.ts @@ -2,6 +2,9 @@ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; import type { PluginRuntime } from "./runtime-api.js"; const { setRuntime: setQaChannelRuntime, getRuntime: getQaChannelRuntime } = - createPluginRuntimeStore("QA channel runtime not initialized"); + createPluginRuntimeStore({ + pluginId: "qa-channel", + errorMessage: "QA channel runtime not initialized", + }); export { getQaChannelRuntime, setQaChannelRuntime }; diff --git a/extensions/qa-lab/src/bundled-plugin-staging.ts b/extensions/qa-lab/src/bundled-plugin-staging.ts new file mode 100644 index 00000000000..d26b8a5363e --- /dev/null +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -0,0 +1,417 @@ +import { existsSync } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; + +const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([ + "image-generation-core", + "media-understanding-core", + "speech-core", +]); +const QA_OPENAI_PLUGIN_ID = "openai"; +const QA_BUNDLED_PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +function assertSafeQaBundledPluginId(pluginId: string) { + if (!QA_BUNDLED_PLUGIN_ID_PATTERN.test(pluginId)) { + throw new Error(`invalid QA bundled plugin id: ${pluginId}`); + } +} + +function parseStableSemverFloor(value: string | undefined) { + if (!value) { + return null; + } + const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/); + if (!match) { + return null; + } + return { + major: Number.parseInt(match[1] ?? "", 10), + minor: Number.parseInt(match[2] ?? "", 10), + patch: Number.parseInt(match[3] ?? "", 10), + label: `${match[1]}.${match[2]}.${match[3]}`, + }; +} + +function compareSemverFloors( + left: ReturnType, + right: ReturnType, +) { + if (!left && !right) { + return 0; + } + if (!left) { + return -1; + } + if (!right) { + return 1; + } + if (left.major !== right.major) { + return left.major - right.major; + } + if (left.minor !== right.minor) { + return left.minor - right.minor; + } + return left.patch - right.patch; +} + +function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) { + return ( + config.api === "openai-responses" || + config.models.some((model) => model.api === "openai-responses") + ); +} + +export function resolveQaBundledPluginSourceDir(params: { repoRoot: string; pluginId: string }) { + assertSafeQaBundledPluginId(params.pluginId); + const candidates = [ + path.join(params.repoRoot, "dist", "extensions", params.pluginId), + path.join(params.repoRoot, "dist-runtime", "extensions", params.pluginId), + path.join(params.repoRoot, "extensions", params.pluginId), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + +function resolveQaBundledPluginScanRoots(repoRoot: string) { + return [ + path.join(repoRoot, "dist", "extensions"), + path.join(repoRoot, "dist-runtime", "extensions"), + path.join(repoRoot, "extensions"), + ].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index); +} + +export async function resolveQaOwnerPluginIdsForProviderIds(params: { + repoRoot: string; + providerIds: readonly string[]; + providerConfigs?: Record; +}) { + const providerIds = [ + ...new Set(params.providerIds.map((providerId) => providerId.trim())), + ].filter((providerId) => providerId.length > 0); + if (providerIds.length === 0) { + return []; + } + const remainingProviderIds = new Set(providerIds); + const ownerPluginIds = new Set(); + const visitedPluginIds = new Set(); + for (const sourceRoot of resolveQaBundledPluginScanRoots(params.repoRoot)) { + for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const manifestPath = path.join(sourceRoot, entry.name, "openclaw.plugin.json"); + if (!existsSync(manifestPath)) { + continue; + } + const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { + id?: unknown; + providers?: unknown; + cliBackends?: unknown; + }; + const pluginId = typeof manifest.id === "string" ? manifest.id.trim() : entry.name; + if (!pluginId || visitedPluginIds.has(pluginId)) { + continue; + } + visitedPluginIds.add(pluginId); + const ownedIds = new Set( + [ + pluginId, + ...(Array.isArray(manifest.providers) ? manifest.providers : []), + ...(Array.isArray(manifest.cliBackends) ? manifest.cliBackends : []), + ].filter((ownedId): ownedId is string => typeof ownedId === "string"), + ); + for (const providerId of providerIds) { + if (!ownedIds.has(providerId)) { + continue; + } + ownerPluginIds.add(pluginId); + remainingProviderIds.delete(providerId); + } + } + } + for (const providerId of remainingProviderIds) { + const providerConfig = params.providerConfigs?.[providerId]; + if (providerConfig && isQaOpenAiResponsesProviderConfig(providerConfig)) { + ownerPluginIds.add(QA_OPENAI_PLUGIN_ID); + continue; + } + ownerPluginIds.add(providerId); + } + return [...ownerPluginIds]; +} + +function collectQaBundledPluginIds(params: { + repoRoot: string; + allowedPluginIds: readonly string[]; +}) { + const pluginIds = new Set( + params.allowedPluginIds.map((pluginId) => { + assertSafeQaBundledPluginId(pluginId); + return pluginId; + }), + ); + for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) { + if ( + resolveQaBundledPluginSourceDir({ + repoRoot: params.repoRoot, + pluginId, + }) + ) { + pluginIds.add(pluginId); + } + } + return [...pluginIds]; +} + +function resolveQaStagedBundledTreeName(repoRoot: string) { + if (existsSync(path.join(repoRoot, "dist"))) { + return "dist"; + } + if (existsSync(path.join(repoRoot, "dist-runtime"))) { + return "dist-runtime"; + } + return "dist"; +} + +function resolveQaBuiltBundledPluginTreeRoot(params: { repoRoot: string; sourceDir: string }) { + const sourceDir = path.resolve(params.sourceDir); + for (const treeName of ["dist", "dist-runtime"] as const) { + const extensionsRoot = path.join(params.repoRoot, treeName, "extensions"); + const relativeSourceDir = path.relative(extensionsRoot, sourceDir); + if ( + relativeSourceDir.length > 0 && + !relativeSourceDir.startsWith("..") && + !path.isAbsolute(relativeSourceDir) + ) { + return path.join(params.repoRoot, treeName); + } + } + return null; +} + +async function symlinkQaStagedDirEntry(params: { + sourcePath: string; + targetPath: string; + directory?: boolean; +}) { + await fs.symlink( + params.sourcePath, + params.targetPath, + params.directory ? (process.platform === "win32" ? "junction" : "dir") : "file", + ); +} + +async function resolveQaStagedDirEntryDirectory(params: { + sourcePath: string; + entry?: { + isDirectory(): boolean; + isSymbolicLink(): boolean; + }; +}) { + if (params.entry?.isDirectory()) { + return true; + } + if (params.entry?.isSymbolicLink()) { + return (await fs.stat(params.sourcePath)).isDirectory(); + } + if (params.entry) { + return false; + } + return (await fs.lstat(params.sourcePath)).isDirectory(); +} + +async function seedQaStagedNodeModules(params: { repoRoot: string; stagedRoot: string }) { + const sourceNodeModulesDir = path.join(params.repoRoot, "node_modules"); + if (!existsSync(sourceNodeModulesDir)) { + return; + } + const stagedNodeModulesDir = path.join(params.stagedRoot, "node_modules"); + await fs.mkdir(stagedNodeModulesDir, { recursive: true }); + for (const entry of await fs.readdir(sourceNodeModulesDir, { withFileTypes: true })) { + if (entry.name === "openclaw") { + continue; + } + await symlinkQaStagedDirEntry({ + sourcePath: path.join(sourceNodeModulesDir, entry.name), + targetPath: path.join(stagedNodeModulesDir, entry.name), + directory: await resolveQaStagedDirEntryDirectory({ + sourcePath: path.join(sourceNodeModulesDir, entry.name), + entry, + }), + }); + } +} + +function collectQaBuiltTreeRoots(params: { + repoRoot: string; + stagedPluginIds: readonly string[]; + stagedTreeName: string; +}) { + const treeRoots = new Set(); + treeRoots.add(path.join(params.repoRoot, params.stagedTreeName)); + for (const pluginId of params.stagedPluginIds) { + const sourceDir = resolveQaBundledPluginSourceDir({ + repoRoot: params.repoRoot, + pluginId, + }); + if (!sourceDir) { + continue; + } + const builtTreeRoot = resolveQaBuiltBundledPluginTreeRoot({ + repoRoot: params.repoRoot, + sourceDir, + }); + if (builtTreeRoot) { + treeRoots.add(builtTreeRoot); + } + } + return [...treeRoots]; +} + +async function seedQaStagedBuiltTreeRoots(params: { + stagedTreeRoot: string; + sourceTreeRoots: readonly string[]; +}) { + for (const sourceTreeRoot of params.sourceTreeRoots) { + if (!existsSync(sourceTreeRoot)) { + continue; + } + for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) { + if (entry.name === "extensions") { + continue; + } + const targetPath = path.join(params.stagedTreeRoot, entry.name); + if (existsSync(targetPath)) { + continue; + } + await symlinkQaStagedDirEntry({ + sourcePath: path.join(sourceTreeRoot, entry.name), + targetPath, + directory: await resolveQaStagedDirEntryDirectory({ + sourcePath: path.join(sourceTreeRoot, entry.name), + entry, + }), + }); + } + } +} + +export async function resolveQaRuntimeHostVersion(params: { + repoRoot: string; + allowedPluginIds: readonly string[]; +}) { + const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8"); + const rootPackage = JSON.parse(rootPackageRaw) as { version?: string }; + let selected = parseStableSemverFloor(rootPackage.version); + const stagedPluginIds = collectQaBundledPluginIds({ + repoRoot: params.repoRoot, + allowedPluginIds: params.allowedPluginIds, + }); + + for (const pluginId of stagedPluginIds) { + const sourceDir = resolveQaBundledPluginSourceDir({ + repoRoot: params.repoRoot, + pluginId, + }); + if (!sourceDir) { + continue; + } + const packagePath = path.join(sourceDir, "package.json"); + if (!existsSync(packagePath)) { + continue; + } + const packageRaw = await fs.readFile(packagePath, "utf8"); + const packageJson = JSON.parse(packageRaw) as { + openclaw?: { + install?: { + minHostVersion?: string; + }; + }; + }; + const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion); + if (compareSemverFloors(candidate, selected) > 0) { + selected = candidate; + } + } + + return selected?.label; +} + +export async function createQaBundledPluginsDir(params: { + repoRoot: string; + tempRoot: string; + allowedPluginIds: readonly string[]; +}) { + const stagedPluginIds = collectQaBundledPluginIds({ + repoRoot: params.repoRoot, + allowedPluginIds: params.allowedPluginIds, + }); + const stagedRoot = path.join( + params.repoRoot, + ".artifacts", + "qa-runtime", + path.basename(params.tempRoot), + ); + await fs.rm(stagedRoot, { recursive: true, force: true }); + await fs.mkdir(stagedRoot, { recursive: true }); + await fs.copyFile( + path.join(params.repoRoot, "package.json"), + path.join(stagedRoot, "package.json"), + ); + await seedQaStagedNodeModules({ + repoRoot: params.repoRoot, + stagedRoot, + }); + const stagedOpenClawPackageDir = path.join(stagedRoot, "node_modules", "openclaw"); + await fs.mkdir(stagedOpenClawPackageDir, { recursive: true }); + await fs.copyFile( + path.join(params.repoRoot, "package.json"), + path.join(stagedOpenClawPackageDir, "package.json"), + ); + const stagedTreeName = resolveQaStagedBundledTreeName(params.repoRoot); + const stagedTreeRoot = path.join(stagedRoot, stagedTreeName); + await fs.mkdir(stagedTreeRoot, { recursive: true }); + await seedQaStagedBuiltTreeRoots({ + stagedTreeRoot, + sourceTreeRoots: collectQaBuiltTreeRoots({ + repoRoot: params.repoRoot, + stagedPluginIds, + stagedTreeName, + }), + }); + if (stagedTreeName === "dist-runtime" && !existsSync(path.join(stagedRoot, "dist"))) { + const repoDistDir = path.join(params.repoRoot, "dist"); + const stagedDistTarget = existsSync(repoDistDir) ? repoDistDir : stagedTreeRoot; + await symlinkQaStagedDirEntry({ + sourcePath: stagedDistTarget, + targetPath: path.join(stagedRoot, "dist"), + directory: true, + }); + } + const bundledPluginsDir = path.join(stagedTreeRoot, "extensions"); + await fs.mkdir(bundledPluginsDir, { recursive: true }); + for (const pluginId of stagedPluginIds) { + const sourceDir = resolveQaBundledPluginSourceDir({ + repoRoot: params.repoRoot, + pluginId, + }); + if (!sourceDir) { + throw new Error(`qa bundled plugin not found: ${pluginId}`); + } + await fs.cp(sourceDir, path.join(bundledPluginsDir, pluginId), { recursive: true }); + } + await symlinkQaStagedDirEntry({ + sourcePath: path.join(stagedRoot, "dist"), + targetPath: path.join(stagedOpenClawPackageDir, "dist"), + directory: true, + }); + return { + bundledPluginsDir, + stagedRoot, + }; +} diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 2b445abe97f..74008bda948 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -2,19 +2,39 @@ import { spawn } from "node:child_process"; import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { __testing, buildQaRuntimeEnv, resolveQaControlUiRoot } from "./gateway-child.js"; +import { + __testing, + buildQaRuntimeEnv, + resolveQaControlUiRoot, + startQaGatewayChild, +} from "./gateway-child.js"; const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); +const resolveQaNodeExecPathMock = vi.hoisted(() => vi.fn(async () => process.execPath)); +const qaTempPathState = vi.hoisted(() => ({ + preferredTmpDir: process.env.TMPDIR || "/tmp", +})); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); +vi.mock("openclaw/plugin-sdk/temp-path", () => ({ + resolvePreferredOpenClawTmpDir: () => qaTempPathState.preferredTmpDir, +})); + +vi.mock("./node-exec.js", () => ({ + resolveQaNodeExecPath: resolveQaNodeExecPathMock, +})); + const cleanups: Array<() => Promise> = []; afterEach(async () => { fetchWithSsrFGuardMock.mockReset(); + resolveQaNodeExecPathMock.mockReset(); + qaTempPathState.preferredTmpDir = process.env.TMPDIR || "/tmp"; while (cleanups.length > 0) { await cleanups.pop()?.(); } @@ -36,6 +56,28 @@ function createParams(baseEnv?: NodeJS.ProcessEnv) { } describe("buildQaRuntimeEnv", () => { + it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => { + const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-")); + cleanups.push(async () => { + await rm(tempParent, { recursive: true, force: true }); + }); + qaTempPathState.preferredTmpDir = tempParent; + resolveQaNodeExecPathMock.mockRejectedValueOnce(new Error("node missing")); + + await expect( + startQaGatewayChild({ + repoRoot: process.cwd(), + transport: { + requiredPluginIds: [], + createGatewayConfig: () => ({}), + }, + transportBaseUrl: "http://127.0.0.1:43123", + }), + ).rejects.toThrow("node missing"); + + await expect(readdir(tempParent)).resolves.toEqual([]); + }); + it("keeps the slow-reply QA opt-out enabled under fast mode", () => { const env = buildQaRuntimeEnv({ ...createParams(), @@ -624,7 +666,7 @@ describe("resolveQaControlUiRoot", () => { }); describe("qa bundled plugin dir", () => { - it("prefers the built bundled plugin tree when present", async () => { + it("prefers a built bundled plugin when present", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-root-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); @@ -646,10 +688,30 @@ describe("qa bundled plugin dir", () => { "utf8", ); await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true }); + await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8"); - expect(__testing.resolveQaBundledPluginsSourceRoot(repoRoot)).toBe( - path.join(repoRoot, "dist", "extensions"), - ); + expect( + __testing.resolveQaBundledPluginSourceDir({ + repoRoot, + pluginId: "qa-channel", + }), + ).toBe(path.join(repoRoot, "dist", "extensions", "qa-channel")); + }); + + it("falls back to the source bundled plugin when no built copy exists", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-root-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true }); + await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8"); + + expect( + __testing.resolveQaBundledPluginSourceDir({ + repoRoot, + pluginId: "qa-channel", + }), + ).toBe(path.join(repoRoot, "extensions", "qa-channel")); }); it("creates a scoped bundled plugin tree for allowed plugins plus always-allowed runtime facades", async () => { @@ -657,10 +719,47 @@ describe("qa bundled plugin dir", () => { cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); + await writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk/account-id": { + default: "./dist/plugin-sdk/account-id.js", + }, + }, + }, + null, + 2, + ), + "utf8", + ); await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "speech-core"), { recursive: true }); await mkdir(path.join(repoRoot, "dist", "extensions", "unused-plugin"), { recursive: true }); + await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true }); + await writeFile( + path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"), + "export const normalizeAccountId = (value) => value.toLowerCase();\n", + "utf8", + ); + await writeFile( + path.join(repoRoot, "dist", "extensions", "qa-channel", "package.json"), + JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2), + "utf8", + ); + await writeFile( + path.join(repoRoot, "dist", "extensions", "qa-channel", "index.js"), + [ + 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";', + 'export const accountId = normalizeAccountId("QA");', + "", + ].join("\n"), + "utf8", + ); await writeFile(path.join(repoRoot, "dist", "shared-chunk-abc123.js"), "export {};\n", "utf8"); const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-target-")); cleanups.push(async () => { @@ -691,6 +790,20 @@ describe("qa bundled plugin dir", () => { expect(stagedRoot).toBe( path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)), ); + expect(stagedRoot).not.toBeNull(); + if (!stagedRoot) { + throw new Error("expected staged runtime root"); + } + await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain( + '"name": "openclaw"', + ); + await expect( + import( + `${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.js")).href}?t=${Date.now()}` + ), + ).resolves.toMatchObject({ + accountId: "qa", + }); expect((await lstat(path.join(bundledPluginsDir, "qa-channel"))).isDirectory()).toBe(true); expect((await lstat(path.join(bundledPluginsDir, "memory-core"))).isDirectory()).toBe(true); expect((await lstat(path.join(bundledPluginsDir, "speech-core"))).isDirectory()).toBe(true); @@ -708,6 +821,209 @@ describe("qa bundled plugin dir", () => { ).resolves.toBeTruthy(); }); + it("preserves dist-runtime-only root chunks when dist also exists", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-runtime-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf8", + ); + await mkdir(path.join(repoRoot, "dist"), { recursive: true }); + await writeFile( + path.join(repoRoot, "dist", "shared-dist.js"), + 'export const dist = "dist";\n', + "utf8", + ); + await mkdir(path.join(repoRoot, "dist-runtime", "extensions", "runtime-only"), { + recursive: true, + }); + await writeFile( + path.join(repoRoot, "dist-runtime", "runtime-chunk.js"), + 'export const marker = "runtime";\n', + "utf8", + ); + await writeFile( + path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "package.json"), + JSON.stringify({ name: "@openclaw/runtime-only", type: "module" }, null, 2), + "utf8", + ); + await writeFile( + path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "index.js"), + ['import { marker } from "../../runtime-chunk.js";', "export { marker };", ""].join("\n"), + "utf8", + ); + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-target-")); + cleanups.push(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + const { bundledPluginsDir } = await __testing.createQaBundledPluginsDir({ + repoRoot, + tempRoot, + allowedPluginIds: ["runtime-only"], + }); + + expect(bundledPluginsDir).toBe( + path.join( + repoRoot, + ".artifacts", + "qa-runtime", + path.basename(tempRoot), + "dist", + "extensions", + ), + ); + await expect( + import( + `${pathToFileURL(path.join(bundledPluginsDir, "runtime-only", "index.js")).href}?t=${Date.now()}` + ), + ).resolves.toMatchObject({ + marker: "runtime", + }); + await expect( + lstat( + path.join( + repoRoot, + ".artifacts", + "qa-runtime", + path.basename(tempRoot), + "dist", + "runtime-chunk.js", + ), + ), + ).resolves.toBeTruthy(); + }); + + it("rejects invalid bundled plugin ids before staging paths are built", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-id-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + await writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify({ name: "openclaw", type: "module" }, null, 2), + "utf8", + ); + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-target-")); + cleanups.push(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + await expect( + __testing.createQaBundledPluginsDir({ + repoRoot, + tempRoot, + allowedPluginIds: ["../escape"], + }), + ).rejects.toThrow("invalid QA bundled plugin id: ../escape"); + }); + + it("stages source-only bundled plugins into a repo-like runtime root with node_modules", async () => { + const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-stage-")); + cleanups.push(async () => { + await rm(repoRoot, { recursive: true, force: true }); + }); + const fakeDepStoreRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-store-")); + cleanups.push(async () => { + await rm(fakeDepStoreRoot, { recursive: true, force: true }); + }); + await writeFile( + path.join(repoRoot, "package.json"), + JSON.stringify( + { + name: "openclaw", + type: "module", + exports: { + "./plugin-sdk/account-id": { + default: "./dist/plugin-sdk/account-id.js", + }, + }, + }, + null, + 2, + ), + "utf8", + ); + await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true }); + await writeFile( + path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"), + "export const normalizeAccountId = (value) => value.toLowerCase();\n", + "utf8", + ); + await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true }); + await writeFile( + path.join(repoRoot, "extensions", "qa-channel", "package.json"), + JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2), + "utf8", + ); + await writeFile( + path.join(repoRoot, "extensions", "qa-channel", "index.ts"), + [ + 'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";', + 'import { marker } from "fake-dep";', + 'export const accountId = `${normalizeAccountId("QA")}:${marker}`;', + "", + ].join("\n"), + "utf8", + ); + const fakeDepPackageDir = path.join(fakeDepStoreRoot, "fake-dep"); + await mkdir(fakeDepPackageDir, { recursive: true }); + await writeFile( + path.join(fakeDepPackageDir, "package.json"), + JSON.stringify({ name: "fake-dep", type: "module" }, null, 2), + "utf8", + ); + await writeFile( + path.join(fakeDepPackageDir, "index.js"), + 'export const marker = "ok";\n', + "utf8", + ); + await mkdir(path.join(repoRoot, "node_modules"), { recursive: true }); + await symlink(fakeDepPackageDir, path.join(repoRoot, "node_modules", "fake-dep"), "dir"); + const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-target-")); + cleanups.push(async () => { + await rm(tempRoot, { recursive: true, force: true }); + }); + + const { bundledPluginsDir, stagedRoot } = await __testing.createQaBundledPluginsDir({ + repoRoot, + tempRoot, + allowedPluginIds: ["qa-channel"], + }); + + expect(bundledPluginsDir).toBe( + path.join( + repoRoot, + ".artifacts", + "qa-runtime", + path.basename(tempRoot), + "dist", + "extensions", + ), + ); + if (!stagedRoot) { + throw new Error("expected staged runtime root"); + } + await expect( + import( + `${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.ts")).href}?t=${Date.now()}` + ), + ).resolves.toMatchObject({ + accountId: "qa:ok", + }); + await expect( + lstat(path.join(stagedRoot, "node_modules", "fake-dep")).then((stats) => + stats.isSymbolicLink(), + ), + ).resolves.toBe(true); + await expect( + readFile(path.join(stagedRoot, "node_modules", "fake-dep", "index.js"), "utf8"), + ).resolves.toContain('marker = "ok"'); + }); + it("maps cli backend provider ids to their owning bundled plugin ids", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-plugin-owner-")); cleanups.push(async () => { @@ -855,7 +1171,6 @@ describe("qa bundled plugin dir", () => { await expect( __testing.resolveQaRuntimeHostVersion({ repoRoot, - bundledPluginsSourceRoot: bundledRoot, allowedPluginIds: ["memory-core", "qa-channel"], }), ).resolves.toBe("2026.4.8"); @@ -888,7 +1203,6 @@ describe("qa bundled plugin dir", () => { await expect( __testing.resolveQaRuntimeHostVersion({ repoRoot, - bundledPluginsSourceRoot: bundledRoot, allowedPluginIds: ["qa-channel"], }), ).resolves.toBe("2026.4.9"); diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 746e47034e2..a361bc878d2 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -16,10 +16,17 @@ import { import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; +import { + createQaBundledPluginsDir, + resolveQaBundledPluginSourceDir, + resolveQaOwnerPluginIdsForProviderIds, + resolveQaRuntimeHostVersion, +} from "./bundled-plugin-staging.js"; import { assertRepoBoundPath, ensureRepoBoundDirectory } from "./cli-paths.js"; import { formatQaGatewayLogsForError, redactQaGatewayDebugText } from "./gateway-log-redaction.js"; import { startQaGatewayRpcClient } from "./gateway-rpc-client.js"; import { splitQaModelRef } from "./model-selection.js"; +import { resolveQaNodeExecPath } from "./node-exec.js"; import { seedQaAgentWorkspace } from "./qa-agent-workspace.js"; import { buildQaGatewayConfig, type QaThinkingLevel } from "./qa-gateway-config.js"; import type { QaTransportAdapter } from "./qa-transport.js"; @@ -78,18 +85,9 @@ const QA_MOCK_BLOCKED_ENV_KEY_PATTERNS = Object.freeze([ const QA_LIVE_PROVIDER_CONFIG_PATH_ENV = "OPENCLAW_QA_LIVE_PROVIDER_CONFIG_PATH"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN"; -// Keep this in sync with the facade runtime's always-allowed bundled surfaces. -// QA child staging must include these runtime helpers even when they are not in -// cfg.plugins.allow, otherwise lazy facade loads can fail inside the child. -const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([ - "image-generation-core", - "media-understanding-core", - "speech-core", -]); const QA_LIVE_SETUP_TOKEN_VALUE_ENV = "OPENCLAW_LIVE_SETUP_TOKEN_VALUE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ENV = "OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE"; const QA_LIVE_ANTHROPIC_SETUP_TOKEN_PROFILE_ID = "anthropic:qa-setup-token"; -const QA_OPENAI_PLUGIN_ID = "openai"; const QA_LIVE_CLI_BACKEND_PRESERVE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV"; const QA_LIVE_CLI_BACKEND_AUTH_MODE_ENV = "OPENCLAW_LIVE_CLI_BACKEND_AUTH_MODE"; export type QaCliBackendAuthMode = "auto" | "api-key" | "subscription"; @@ -526,7 +524,7 @@ export const __testing = { stageQaMockAuthProfiles, resolveQaLiveCliAuthEnv, resolveQaOwnerPluginIdsForProviderIds, - resolveQaBundledPluginsSourceRoot, + resolveQaBundledPluginSourceDir, resolveQaRuntimeHostVersion, createQaBundledPluginsDir, stopQaGatewayChildProcessTree, @@ -580,77 +578,6 @@ async function stopQaGatewayChildProcessTree( await waitForQaGatewayChildExit(child, opts?.forceTimeoutMs ?? 2_000); } -function resolveQaBundledPluginsSourceRoot(repoRoot: string) { - const candidates = [ - path.join(repoRoot, "dist", "extensions"), - path.join(repoRoot, "dist-runtime", "extensions"), - path.join(repoRoot, "extensions"), - ]; - for (const candidate of candidates) { - if (existsSync(candidate)) { - return candidate; - } - } - throw new Error("failed to resolve qa bundled plugins source root"); -} - -async function resolveQaOwnerPluginIdsForProviderIds(params: { - repoRoot: string; - providerIds: readonly string[]; - providerConfigs?: Record; -}) { - const providerIds = [ - ...new Set(params.providerIds.map((providerId) => providerId.trim())), - ].filter((providerId) => providerId.length > 0); - if (providerIds.length === 0) { - return []; - } - const remainingProviderIds = new Set(providerIds); - const ownerPluginIds = new Set(); - const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot); - for (const entry of await fs.readdir(sourceRoot, { withFileTypes: true })) { - if (!entry.isDirectory()) { - continue; - } - const manifestPath = path.join(sourceRoot, entry.name, "openclaw.plugin.json"); - if (!existsSync(manifestPath)) { - continue; - } - const manifest = JSON.parse(await fs.readFile(manifestPath, "utf8")) as { - id?: unknown; - providers?: unknown; - cliBackends?: unknown; - }; - const pluginId = typeof manifest.id === "string" ? manifest.id.trim() : entry.name; - if (!pluginId) { - continue; - } - const ownedIds = new Set( - [ - pluginId, - ...(Array.isArray(manifest.providers) ? manifest.providers : []), - ...(Array.isArray(manifest.cliBackends) ? manifest.cliBackends : []), - ].filter((ownedId): ownedId is string => typeof ownedId === "string"), - ); - for (const providerId of providerIds) { - if (!ownedIds.has(providerId)) { - continue; - } - ownerPluginIds.add(pluginId); - remainingProviderIds.delete(providerId); - } - } - for (const providerId of remainingProviderIds) { - const providerConfig = params.providerConfigs?.[providerId]; - if (providerConfig && isQaOpenAiResponsesProviderConfig(providerConfig)) { - ownerPluginIds.add(QA_OPENAI_PLUGIN_ID); - continue; - } - ownerPluginIds.add(providerId); - } - return [...ownerPluginIds]; -} - function resolveQaUserPath(value: string, env: NodeJS.ProcessEnv = process.env) { if (value === "~") { return env.HOME ?? os.homedir(); @@ -677,13 +604,6 @@ function isQaModelProviderConfig(value: unknown): value is ModelProviderConfig { return isRecord(value) && typeof value.baseUrl === "string" && Array.isArray(value.models); } -function isQaOpenAiResponsesProviderConfig(config: ModelProviderConfig) { - return ( - config.api === "openai-responses" || - config.models.some((model) => model.api === "openai-responses") - ); -} - async function readQaLiveProviderConfigOverrides(params: { providerIds: readonly string[]; env?: NodeJS.ProcessEnv; @@ -727,157 +647,6 @@ async function readQaLiveProviderConfigOverrides(params: { } } -function parseStableSemverFloor(value: string | undefined) { - if (!value) { - return null; - } - const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/); - if (!match) { - return null; - } - return { - major: Number.parseInt(match[1] ?? "", 10), - minor: Number.parseInt(match[2] ?? "", 10), - patch: Number.parseInt(match[3] ?? "", 10), - label: `${match[1]}.${match[2]}.${match[3]}`, - }; -} - -function compareSemverFloors( - left: ReturnType, - right: ReturnType, -) { - if (!left && !right) { - return 0; - } - if (!left) { - return -1; - } - if (!right) { - return 1; - } - if (left.major !== right.major) { - return left.major - right.major; - } - if (left.minor !== right.minor) { - return left.minor - right.minor; - } - return left.patch - right.patch; -} - -async function resolveQaRuntimeHostVersion(params: { - repoRoot: string; - bundledPluginsSourceRoot: string; - allowedPluginIds: readonly string[]; -}) { - const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8"); - const rootPackage = JSON.parse(rootPackageRaw) as { version?: string }; - let selected = parseStableSemverFloor(rootPackage.version); - const stagedPluginIds = collectQaBundledPluginIds({ - sourceRoot: params.bundledPluginsSourceRoot, - allowedPluginIds: params.allowedPluginIds, - }); - - for (const pluginId of stagedPluginIds) { - const packagePath = path.join(params.bundledPluginsSourceRoot, pluginId, "package.json"); - if (!existsSync(packagePath)) { - continue; - } - const packageRaw = await fs.readFile(packagePath, "utf8"); - const packageJson = JSON.parse(packageRaw) as { - openclaw?: { - install?: { - minHostVersion?: string; - }; - }; - }; - const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion); - if (compareSemverFloors(candidate, selected) > 0) { - selected = candidate; - } - } - - return selected?.label; -} - -function collectQaBundledPluginIds(params: { - sourceRoot: string; - allowedPluginIds: readonly string[]; -}) { - const pluginIds = new Set(params.allowedPluginIds); - for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) { - if (existsSync(path.join(params.sourceRoot, pluginId))) { - pluginIds.add(pluginId); - } - } - return [...pluginIds]; -} - -async function createQaBundledPluginsDir(params: { - repoRoot: string; - tempRoot: string; - allowedPluginIds: readonly string[]; -}) { - const sourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot); - const stagedPluginIds = collectQaBundledPluginIds({ - sourceRoot, - allowedPluginIds: params.allowedPluginIds, - }); - const sourceTreeRoot = path.dirname(sourceRoot); - if ( - sourceTreeRoot === path.join(params.repoRoot, "dist") || - sourceTreeRoot === path.join(params.repoRoot, "dist-runtime") - ) { - const stagedRoot = path.join( - params.repoRoot, - ".artifacts", - "qa-runtime", - path.basename(params.tempRoot), - ); - await fs.rm(stagedRoot, { recursive: true, force: true }); - await fs.mkdir(stagedRoot, { recursive: true }); - const stagedTreeRoot = path.join(stagedRoot, path.basename(sourceTreeRoot)); - await fs.mkdir(stagedTreeRoot, { recursive: true }); - for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) { - const sourcePath = path.join(sourceTreeRoot, entry.name); - const targetPath = path.join(stagedTreeRoot, entry.name); - if (entry.name === "extensions") { - await fs.mkdir(targetPath, { recursive: true }); - for (const pluginId of stagedPluginIds) { - const sourceDir = path.join(sourceRoot, pluginId); - if (!existsSync(sourceDir)) { - throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`); - } - await fs.cp(sourceDir, path.join(targetPath, pluginId), { recursive: true }); - } - continue; - } - await fs.symlink(sourcePath, targetPath); - } - const stagedExtensionsDir = path.join(stagedTreeRoot, "extensions"); - return { - bundledPluginsDir: stagedExtensionsDir, - stagedRoot, - }; - } - - const bundledPluginsDir = path.join(params.tempRoot, "bundled-plugins"); - await fs.mkdir(bundledPluginsDir, { recursive: true }); - for (const pluginId of stagedPluginIds) { - const sourceDir = path.join(sourceRoot, pluginId); - if (!existsSync(sourceDir)) { - throw new Error(`qa bundled plugin not found: ${pluginId} (${sourceDir})`); - } - // Plugin discovery walks real directories; copying avoids symlink-only - // trees being skipped by Dirent-based scans in the child runtime. - await fs.cp(sourceDir, path.join(bundledPluginsDir, pluginId), { recursive: true }); - } - return { - bundledPluginsDir, - stagedRoot: null, - }; -} - async function waitForGatewayReady(params: { baseUrl: string; logs: () => string; @@ -1053,6 +822,7 @@ export async function startQaGatewayChild(params: { let env: NodeJS.ProcessEnv | null = null; try { + const nodeExecPath = await resolveQaNodeExecPath(); for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) { gatewayPort = await getFreePort(); baseUrl = `http://127.0.0.1:${gatewayPort}`; @@ -1068,7 +838,6 @@ export async function startQaGatewayChild(params: { ); }, ); - const bundledPluginsSourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot); const { bundledPluginsDir, stagedRoot } = await createQaBundledPluginsDir({ repoRoot: params.repoRoot, tempRoot, @@ -1077,7 +846,6 @@ export async function startQaGatewayChild(params: { stagedBundledPluginsRoot = stagedRoot; const runtimeHostVersion = await resolveQaRuntimeHostVersion({ repoRoot: params.repoRoot, - bundledPluginsSourceRoot, allowedPluginIds, }); env = buildQaRuntimeEnv({ @@ -1105,7 +873,7 @@ export async function startQaGatewayChild(params: { } const attemptChild = spawn( - process.execPath, + nodeExecPath, [ distEntryPath, "gateway", diff --git a/extensions/qa-lab/src/model-catalog.runtime.ts b/extensions/qa-lab/src/model-catalog.runtime.ts index afcf97cd847..22db4d6c6ac 100644 --- a/extensions/qa-lab/src/model-catalog.runtime.ts +++ b/extensions/qa-lab/src/model-catalog.runtime.ts @@ -7,6 +7,7 @@ import { QA_CHANNEL_REQUIRED_PLUGIN_IDS, } from "./qa-channel-transport.js"; import { buildQaGatewayConfig } from "./qa-gateway-config.js"; +import { resolveQaNodeExecPath } from "./node-exec.js"; const QA_FRONTIER_PROVIDER_IDS = ["anthropic", "google", "openai"] as const; @@ -123,11 +124,12 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa const stdout: Buffer[] = []; const stderr: Buffer[] = []; + const nodeExecPath = await resolveQaNodeExecPath(); await new Promise((resolve, reject) => { let aborted = params.signal?.aborted === true; let forceKillTimer: NodeJS.Timeout | undefined; const child = spawn( - process.execPath, + nodeExecPath, ["dist/index.js", "models", "list", "--all", "--json"], { cwd: params.repoRoot, diff --git a/extensions/qa-lab/src/node-exec.test.ts b/extensions/qa-lab/src/node-exec.test.ts new file mode 100644 index 00000000000..58572d6bb79 --- /dev/null +++ b/extensions/qa-lab/src/node-exec.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { resolveQaNodeExecPath } from "./node-exec.js"; + +describe("resolveQaNodeExecPath", () => { + it("reuses the current exec path when already running under Node", async () => { + await expect( + resolveQaNodeExecPath({ + execPath: "/opt/homebrew/bin/node", + platform: "darwin", + versions: { ...process.versions, bun: undefined }, + }), + ).resolves.toBe("/opt/homebrew/bin/node"); + }); + + it("resolves node from PATH when the parent runtime is bun", async () => { + await expect( + resolveQaNodeExecPath({ + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + versions: { ...process.versions, bun: "1.2.3" }, + execFileImpl: async () => ({ + stdout: "/usr/local/bin/node\n", + stderr: "", + }), + }), + ).resolves.toBe("/usr/local/bin/node"); + }); + + it("throws a clear error when node is unavailable", async () => { + await expect( + resolveQaNodeExecPath({ + execPath: "/opt/homebrew/bin/bun", + platform: "darwin", + versions: { ...process.versions, bun: "1.2.3" }, + execFileImpl: async () => { + throw new Error("missing"); + }, + }), + ).rejects.toThrow("Node not found in PATH"); + }); +}); diff --git a/extensions/qa-lab/src/node-exec.ts b/extensions/qa-lab/src/node-exec.ts new file mode 100644 index 00000000000..dfc7571c2f3 --- /dev/null +++ b/extensions/qa-lab/src/node-exec.ts @@ -0,0 +1,64 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { promisify } from "node:util"; + +type ExecFileAsync = ( + file: string, + args: readonly string[], + options: { + encoding: "utf8"; + env?: NodeJS.ProcessEnv; + }, +) => Promise<{ stdout: string; stderr: string }>; + +const execFileAsync = promisify(execFile) as unknown as ExecFileAsync; + +function isNodeExecPath(execPath: string, platform: NodeJS.Platform): boolean { + const pathModule = platform === "win32" ? path.win32 : path.posix; + const basename = pathModule.basename(execPath).toLowerCase(); + return basename === "node" || basename === "node.exe"; +} + +export async function resolveQaNodeExecPath(params?: { + execPath?: string; + platform?: NodeJS.Platform; + versions?: NodeJS.ProcessVersions; + env?: NodeJS.ProcessEnv; + execFileImpl?: ExecFileAsync; +}): Promise { + const execPath = params?.execPath ?? process.execPath; + const platform = params?.platform ?? process.platform; + const versions = params?.versions ?? process.versions; + if (typeof versions.bun !== "string" && isNodeExecPath(execPath, platform)) { + return execPath; + } + + const locator = platform === "win32" ? "where" : "which"; + const execFileImpl = params?.execFileImpl ?? execFileAsync; + let stdout = ""; + try { + ({ stdout } = await execFileImpl(locator, ["node"], { + encoding: "utf8", + env: params?.env, + })); + } catch { + throw new Error( + "Node not found in PATH. QA live lanes require Node for child gateway and CLI processes.", + ); + } + + const resolved = stdout + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find((entry) => entry.length > 0); + if (!resolved) { + throw new Error( + "Node not found in PATH. QA live lanes require Node for child gateway and CLI processes.", + ); + } + return resolved; +} + +export const __testing = { + isNodeExecPath, +}; diff --git a/extensions/qa-lab/src/qa-channel-transport.ts b/extensions/qa-lab/src/qa-channel-transport.ts index 3c3967b43ba..9f9e74ffb1e 100644 --- a/extensions/qa-lab/src/qa-channel-transport.ts +++ b/extensions/qa-lab/src/qa-channel-transport.ts @@ -12,6 +12,7 @@ import { qaChannelPlugin } from "./runtime-api.js"; export const QA_CHANNEL_ID = "qa-channel"; export const QA_CHANNEL_ACCOUNT_ID = "default"; export const QA_CHANNEL_REQUIRED_PLUGIN_IDS = Object.freeze([QA_CHANNEL_ID]); +export const QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY = 4; async function waitForQaChannelReady(params: { gateway: QaTransportGatewayClient; diff --git a/extensions/qa-lab/src/qa-transport-registry.test.ts b/extensions/qa-lab/src/qa-transport-registry.test.ts new file mode 100644 index 00000000000..df6ce160efc --- /dev/null +++ b/extensions/qa-lab/src/qa-transport-registry.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { normalizeQaTransportId } from "./qa-transport-registry.js"; + +describe("qa transport registry", () => { + it("rejects inherited prototype keys as unsupported transport ids", () => { + expect(() => normalizeQaTransportId("toString")).toThrow("unsupported QA transport: toString"); + expect(() => normalizeQaTransportId("__proto__")).toThrow( + "unsupported QA transport: __proto__", + ); + }); +}); diff --git a/extensions/qa-lab/src/qa-transport-registry.ts b/extensions/qa-lab/src/qa-transport-registry.ts index 6f1050c6403..d6433764618 100644 --- a/extensions/qa-lab/src/qa-transport-registry.ts +++ b/extensions/qa-lab/src/qa-transport-registry.ts @@ -1,29 +1,42 @@ import type { QaBusState } from "./bus-state.js"; -import { createQaChannelTransport } from "./qa-channel-transport.js"; +import { + createQaChannelTransport, + QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY, +} from "./qa-channel-transport.js"; import type { QaTransportAdapter } from "./qa-transport.js"; export type QaTransportId = "qa-channel"; -export function normalizeQaTransportId(input?: string | null): QaTransportId { - const transportId = input?.trim() || "qa-channel"; - switch (transportId) { - case "qa-channel": - return transportId; - default: - throw new Error(`unsupported QA transport: ${transportId}`); +const DEFAULT_QA_TRANSPORT_ID: QaTransportId = "qa-channel"; + +const QA_TRANSPORT_REGISTRY = { + "qa-channel": { + create: createQaChannelTransport, + defaultSuiteConcurrency: QA_CHANNEL_DEFAULT_SUITE_CONCURRENCY, + }, +} as const satisfies Record< + QaTransportId, + { + create: (state: QaBusState) => QaTransportAdapter; + defaultSuiteConcurrency: number; } +>; + +export function normalizeQaTransportId(input?: string | null): QaTransportId { + const transportId = input?.trim() || DEFAULT_QA_TRANSPORT_ID; + if (Object.hasOwn(QA_TRANSPORT_REGISTRY, transportId)) { + return transportId as QaTransportId; + } + throw new Error(`unsupported QA transport: ${transportId}`); } export function createQaTransportAdapter(params: { id: QaTransportId; state: QaBusState; }): QaTransportAdapter { - switch (params.id) { - case "qa-channel": - return createQaChannelTransport(params.state); - default: { - const unsupported: never = params.id; - throw new Error(`unsupported QA transport: ${String(unsupported)}`); - } - } + return QA_TRANSPORT_REGISTRY[params.id].create(params.state); +} + +export function defaultQaSuiteConcurrencyForTransport(id: QaTransportId): number { + return QA_TRANSPORT_REGISTRY[id].defaultSuiteConcurrency; } diff --git a/extensions/qa-lab/src/scenario-flow-runner.test.ts b/extensions/qa-lab/src/scenario-flow-runner.test.ts new file mode 100644 index 00000000000..91b9f327afd --- /dev/null +++ b/extensions/qa-lab/src/scenario-flow-runner.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { createQaBusState } from "./bus-state.js"; +import { runScenarioFlow } from "./scenario-flow-runner.js"; + +describe("scenario-flow-runner", () => { + it("supports qaImport inside flow expressions", async () => { + const result = await runScenarioFlow({ + api: { + state: createQaBusState(), + scenario: { + id: "qa-import", + title: "qa-import", + sourcePath: "qa/scenarios/qa-import.md", + surface: "test", + objective: "test", + successCriteria: ["test"], + execution: { kind: "flow" }, + }, + config: {}, + runScenario: async ( + _name: string, + steps: Array<{ name: string; run: () => Promise }>, + ) => { + const stepResults = []; + for (const step of steps) { + const details = await step.run(); + stepResults.push({ + name: step.name, + status: "pass" as const, + ...(details !== undefined ? { details } : {}), + }); + } + return { + name: "qa-import", + status: "pass" as const, + steps: stepResults, + }; + }, + }, + scenarioTitle: "qa-import", + flow: { + steps: [ + { + name: "uses qaImport", + actions: [ + { + set: "basename", + value: { + expr: '(await qaImport("node:path")).basename("/tmp/skill/SKILL.md")', + }, + }, + { + assert: { + expr: 'basename === "SKILL.md"', + }, + }, + ], + detailsExpr: "basename", + }, + ], + }, + }); + + expect(result).toEqual({ + name: "qa-import", + status: "pass", + steps: [ + { + name: "uses qaImport", + status: "pass", + details: "SKILL.md", + }, + ], + }); + }); +}); diff --git a/extensions/qa-lab/src/scenario-flow-runner.ts b/extensions/qa-lab/src/scenario-flow-runner.ts index 345f511a994..448fd79fc43 100644 --- a/extensions/qa-lab/src/scenario-flow-runner.ts +++ b/extensions/qa-lab/src/scenario-flow-runner.ts @@ -68,6 +68,7 @@ function getPathWithParent( function createEvalContext(api: QaFlowApi, vars: QaFlowVars) { return { ...api, + qaImport: (specifier: string) => import(specifier), vars, ...vars, }; diff --git a/qa/scenarios/bundled-plugin-skill-runtime.md b/qa/scenarios/bundled-plugin-skill-runtime.md index 9b2f8cdfc7e..0a959713abc 100644 --- a/qa/scenarios/bundled-plugin-skill-runtime.md +++ b/qa/scenarios/bundled-plugin-skill-runtime.md @@ -32,8 +32,8 @@ steps: value: expr: |- (async () => { - const { spawnSync } = await import("node:child_process"); - const fsSync = await import("node:fs"); + const { spawnSync } = await qaImport("node:child_process"); + const fsSync = await qaImport("node:fs"); const distRuntimeExtensions = path.join(env.repoRoot, "dist-runtime", "extensions"); const skillPath = path.join( distRuntimeExtensions, diff --git a/qa/scenarios/session-memory-ranking.md b/qa/scenarios/session-memory-ranking.md index a8a70bfc014..dd153b5e6e6 100644 --- a/qa/scenarios/session-memory-ranking.md +++ b/qa/scenarios/session-memory-ranking.md @@ -75,12 +75,19 @@ steps: - 60000 - try: actions: - - set: memoryPath + - set: memoryDir value: - expr: "path.join(env.gateway.workspaceDir, 'MEMORY.md')" + expr: "path.join(env.gateway.workspaceDir, 'memory')" + - call: fs.mkdir + args: + - ref: memoryDir + - recursive: true + - set: staleMemoryPath + value: + expr: "path.join(memoryDir, '2020-01-01.md')" - call: fs.writeFile args: - - ref: memoryPath + - ref: staleMemoryPath - expr: "`${'Project Nebula stale codename: '}${staleFact}.\\n`" - utf8 - set: staleAt @@ -88,7 +95,7 @@ steps: expr: "new Date('2020-01-01T00:00:00.000Z')" - call: fs.utimes args: - - ref: memoryPath + - ref: staleMemoryPath - ref: staleAt - ref: staleAt - set: transcriptsDir diff --git a/scripts/lib/plugin-sdk-private-local-only-subpaths.json b/scripts/lib/plugin-sdk-private-local-only-subpaths.json new file mode 100644 index 00000000000..ba8dd822c2c --- /dev/null +++ b/scripts/lib/plugin-sdk-private-local-only-subpaths.json @@ -0,0 +1,4 @@ +[ + "qa-lab", + "qa-runtime" +] diff --git a/scripts/run-node.mjs b/scripts/run-node.mjs index ebf4c9bb5d9..4c9a41b69cc 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -210,8 +210,8 @@ export const resolveBuildRequirement = (deps) => { } if ( deps.env.OPENCLAW_BUILD_PRIVATE_QA === "1" && - deps.privateQaDistEntry && - statMtime(deps.privateQaDistEntry, deps.fs) == null + ((deps.privateQaDistEntry && statMtime(deps.privateQaDistEntry, deps.fs) == null) || + (deps.privateQaBundledCliEntry && statMtime(deps.privateQaBundledCliEntry, deps.fs) == null)) ) { return { shouldBuild: true, reason: "missing_private_qa_dist" }; } @@ -397,7 +397,8 @@ export async function runNodeMain(params = {}) { path: path.join(deps.cwd, sourceRoot), })); deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath)); - deps.privateQaDistEntry = path.join(deps.distRoot, "extensions", "qa-lab", "cli.js"); + deps.privateQaDistEntry = path.join(deps.distRoot, "plugin-sdk", "qa-lab.js"); + deps.privateQaBundledCliEntry = path.join(deps.distRoot, "extensions", "qa-lab", "cli.js"); if (deps.args[0] === "qa") { deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1"; deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; diff --git a/src/cli/program/private-qa-cli.test.ts b/src/cli/program/private-qa-cli.test.ts new file mode 100644 index 00000000000..2e162bbf895 --- /dev/null +++ b/src/cli/program/private-qa-cli.test.ts @@ -0,0 +1,79 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadPrivateQaCliModule } from "./private-qa-cli.js"; + +describe("private-qa-cli", () => { + const tempDirs: string[] = []; + const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalPrivateQaCli === undefined) { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + } else { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli; + } + }); + + it("loads the private QA CLI from a source checkout path", async () => { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-source-")); + tempDirs.push(repoRoot); + const expectedPaths = new Set([ + path.join(repoRoot, ".git"), + path.join(repoRoot, "src"), + path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js"), + ]); + let importedSpecifier: string | undefined; + const importModule = vi.fn(async (specifier: string) => { + importedSpecifier = specifier; + return { + isQaLabCliAvailable: expect.any(Function), + registerQaLabCli: expect.any(Function), + }; + }); + + const module = await loadPrivateQaCliModule({ + importModule, + resolvePackageRootSync: () => repoRoot, + existsSync: (filePath) => typeof filePath === "string" && expectedPaths.has(filePath), + }); + + expect(importModule).toHaveBeenCalledTimes(1); + expect(importedSpecifier).toContain("/dist/plugin-sdk/qa-lab.js"); + expect(module).toMatchObject({ + isQaLabCliAvailable: expect.any(Function), + registerQaLabCli: expect.any(Function), + }); + }); + + it("rejects non-source package roots even when private QA is enabled", async () => { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-")); + tempDirs.push(root); + fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf8"); + const importModule = vi.fn(async () => ({})); + + expect(() => + loadPrivateQaCliModule({ + resolvePackageRootSync: () => root, + importModule, + }), + ).toThrow("Private QA CLI is only available from an OpenClaw source checkout."); + expect(importModule).not.toHaveBeenCalled(); + }); + + it("rejects when the private QA env flag is disabled", async () => { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + const importModule = vi.fn(async () => ({})); + + expect(() => loadPrivateQaCliModule({ importModule })).toThrow( + "Private QA CLI is only available from an OpenClaw source checkout.", + ); + expect(importModule).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts index e5f425d9a39..9073711d0f9 100644 --- a/src/cli/program/private-qa-cli.ts +++ b/src/cli/program/private-qa-cli.ts @@ -1,8 +1,65 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js"; + +const PRIVATE_QA_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js"); + export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean { return env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1"; } -export function loadPrivateQaCliModule(): Promise> { - const specifier = ["../../plugin-sdk/", "qa", "-lab.js"].join(""); - return import(specifier) as Promise>; +function resolvePrivateQaSourceModuleSpecifier(params?: { + env?: NodeJS.ProcessEnv; + cwd?: string; + argv1?: string; + moduleUrl?: string; + resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync; + existsSync?: typeof fs.existsSync; +}): string | null { + const env = params?.env ?? process.env; + if (!isPrivateQaCliEnabled(env)) { + return null; + } + const resolvePackageRootSync = params?.resolvePackageRootSync ?? resolveOpenClawPackageRootSync; + const packageRoot = resolvePackageRootSync({ + argv1: params?.argv1 ?? process.argv[1], + cwd: params?.cwd ?? process.cwd(), + moduleUrl: params?.moduleUrl ?? import.meta.url, + }); + if (!packageRoot) { + return null; + } + const existsSync = params?.existsSync ?? fs.existsSync; + const sourceModulePath = path.join(packageRoot, PRIVATE_QA_DIST_RELATIVE_PATH); + if ( + !existsSync(path.join(packageRoot, ".git")) || + !existsSync(path.join(packageRoot, "src")) || + !existsSync(sourceModulePath) + ) { + return null; + } + return pathToFileURL(sourceModulePath).href; +} + +async function dynamicImportPrivateQaCliModule( + specifier: string, +): Promise> { + return (await import(specifier)) as Record; +} + +export function loadPrivateQaCliModule(params?: { + env?: NodeJS.ProcessEnv; + cwd?: string; + argv1?: string; + moduleUrl?: string; + resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync; + existsSync?: typeof fs.existsSync; + importModule?: (specifier: string) => Promise>; +}): Promise> { + const specifier = resolvePrivateQaSourceModuleSpecifier(params); + if (!specifier) { + throw new Error("Private QA CLI is only available from an OpenClaw source checkout."); + } + return (params?.importModule ?? dynamicImportPrivateQaCliModule)(specifier); } diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 594714aff44..6720eb3424f 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -25,6 +25,9 @@ const { registerQaLabCli } = vi.hoisted(() => ({ qa.command("run").action(() => undefined); }), })); +const { loadPrivateQaCliModule } = vi.hoisted(() => ({ + loadPrivateQaCliModule: vi.fn(async () => ({ registerQaLabCli })), +})); const { inferAction, registerCapabilityCli } = vi.hoisted(() => { const action = vi.fn(); @@ -37,7 +40,13 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => { vi.mock("../acp-cli.js", () => ({ registerAcpCli })); vi.mock("../nodes-cli.js", () => ({ registerNodesCli })); vi.mock("../capability-cli.js", () => ({ registerCapabilityCli })); -vi.mock("../../plugin-sdk/qa-lab.js", () => ({ registerQaLabCli })); +vi.mock("./private-qa-cli.js", async () => { + const actual = await vi.importActual("./private-qa-cli.js"); + return { + ...actual, + loadPrivateQaCliModule, + }; +}); describe("registerSubCliCommands", () => { const originalArgv = process.argv; @@ -66,6 +75,7 @@ describe("registerSubCliCommands", () => { registerNodesCli.mockClear(); nodesAction.mockClear(); registerQaLabCli.mockClear(); + loadPrivateQaCliModule.mockClear(); registerCapabilityCli.mockClear(); inferAction.mockClear(); }); diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 87e21acae6b..abe84094171 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -19,6 +19,8 @@ const GENERATED_A2UI_BUNDLE = "src/canvas-host/a2ui/a2ui.bundle.js"; const GENERATED_A2UI_BUNDLE_HASH = "src/canvas-host/a2ui/.bundle.hash"; const DIST_ENTRY = "dist/entry.js"; const BUILD_STAMP = "dist/.buildstamp"; +const QA_LAB_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-lab.js"; +const QA_LAB_BUNDLED_CLI_ENTRY = "dist/extensions/qa-lab/cli.js"; const EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts"); const EXTENSION_MANIFEST = bundledPluginFile("demo", "openclaw.plugin.json"); const EXTENSION_PACKAGE = bundledPluginFile("demo", "package.json"); @@ -190,6 +192,29 @@ async function runStatusCommand(params: { }); } +async function runQaCommand(params: { + tmp: string; + spawn: (cmd: string, args: string[]) => ReturnType; + spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string }; + env?: Record; + runRuntimePostBuild?: (params?: { cwd?: string }) => void; +}) { + return await runNodeMain({ + cwd: params.tmp, + args: ["qa", "suite", "--transport", "qa-channel", "--provider-mode", "mock-openai"], + env: { + ...process.env, + OPENCLAW_RUNNER_LOG: "0", + ...params.env, + }, + spawn: params.spawn, + ...(params.spawnSync ? { spawnSync: params.spawnSync } : {}), + ...(params.runRuntimePostBuild ? { runRuntimePostBuild: params.runRuntimePostBuild } : {}), + execPath: process.execPath, + platform: process.platform, + }); +} + async function expectManifestId(tmp: string, relativePath: string, id: string) { await expect( fs.readFile(resolvePath(tmp, relativePath), "utf-8").then((raw) => JSON.parse(raw)), @@ -318,6 +343,80 @@ describe("run-node script", () => { }); }); + it("skips rebuilding for private QA commands when the QA CLI facade is present", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", + [QA_LAB_BUNDLED_CLI_ENTRY]: "export const registerQaLabCli = () => {};\n", + }, + oldPaths: [ + ROOT_SRC, + ROOT_TSCONFIG, + ROOT_PACKAGE, + QA_LAB_PLUGIN_SDK_ENTRY, + QA_LAB_BUNDLED_CLI_ENTRY, + ], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", + }); + const exitCode = await runQaCommand({ tmp, spawn, spawnSync }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + [ + process.execPath, + "openclaw.mjs", + "qa", + "suite", + "--transport", + "qa-channel", + "--provider-mode", + "mock-openai", + ], + ]); + }); + }); + + it("rebuilds private QA commands when the QA bundled CLI surface is missing", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + [QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY], + buildPaths: [DIST_ENTRY, BUILD_STAMP], + }); + + const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({ + gitHead: "abc123\n", + gitStatus: "", + }); + const exitCode = await runQaCommand({ tmp, spawn, spawnSync }); + + expect(exitCode).toBe(0); + expect(spawnCalls).toEqual([ + expectedBuildSpawn(), + [ + process.execPath, + "openclaw.mjs", + "qa", + "suite", + "--transport", + "qa-channel", + "--provider-mode", + "mock-openai", + ], + ]); + }); + }); + it("skips runtime postbuild restaging in watch mode when dist is already current", async () => { await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { await setupTrackedProject(tmp, { diff --git a/src/plugin-sdk/facade-activation-check.runtime.ts b/src/plugin-sdk/facade-activation-check.runtime.ts index 87752a9043d..c022d6aa132 100644 --- a/src/plugin-sdk/facade-activation-check.runtime.ts +++ b/src/plugin-sdk/facade-activation-check.runtime.ts @@ -144,7 +144,10 @@ function getFacadeBoundaryResolvedConfig() { return resolved; } -function getFacadeManifestRegistry(params: { cacheKey: string }): readonly PluginManifestRecord[] { +function getFacadeManifestRegistry(params: { + cacheKey: string; + env?: NodeJS.ProcessEnv; +}): readonly PluginManifestRecord[] { const cached = cachedManifestRegistryByKey.get(params.cacheKey); if (cached) { return cached; @@ -152,6 +155,7 @@ function getFacadeManifestRegistry(params: { cacheKey: string }): readonly Plugi const loaded = loadPluginManifestRegistry({ config: getFacadeBoundaryResolvedConfig().config, cache: true, + ...(params.env ? { env: params.env } : {}), }).plugins; cachedManifestRegistryByKey.set(params.cacheKey, loaded); return loaded; @@ -161,8 +165,12 @@ export function resolveRegistryPluginModuleLocation(params: { dirName: string; artifactBasename: string; resolutionKey: string; + env?: NodeJS.ProcessEnv; }): FacadeModuleLocation | null { - const registry = getFacadeManifestRegistry({ cacheKey: params.resolutionKey }); + const registry = getFacadeManifestRegistry({ + cacheKey: params.resolutionKey, + ...(params.env ? { env: params.env } : {}), + }); type RegistryRecord = (typeof registry)[number]; const tiers: Array<(plugin: RegistryRecord) => boolean> = [ (plugin) => plugin.id === params.dirName, @@ -229,6 +237,7 @@ function resolveBundledMetadataManifestRecord(params: { artifactBasename: string; location: FacadeModuleLocation | null; sourceExtensionsRoot: string; + env?: NodeJS.ProcessEnv; }): FacadePluginManifestLike | null { if (!params.location) { return null; @@ -247,7 +256,7 @@ function resolveBundledMetadataManifestRecord(params: { resolvedDirName, }); } - const bundledPluginsDir = resolveBundledPluginsDir(); + const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); if (!bundledPluginsDir) { return null; } @@ -275,6 +284,7 @@ function resolveBundledPluginManifestRecord(params: { location: FacadeModuleLocation | null; sourceExtensionsRoot: string; resolutionKey: string; + env?: NodeJS.ProcessEnv; }): FacadePluginManifestLike | null { if (cachedFacadeManifestRecordsByKey.has(params.resolutionKey)) { return cachedFacadeManifestRecordsByKey.get(params.resolutionKey) ?? null; @@ -286,7 +296,10 @@ function resolveBundledPluginManifestRecord(params: { return metadataRecord; } - const registry = getFacadeManifestRegistry({ cacheKey: params.resolutionKey }); + const registry = getFacadeManifestRegistry({ + cacheKey: params.resolutionKey, + ...(params.env ? { env: params.env } : {}), + }); const resolved = (params.location ? registry.find((plugin) => { @@ -312,6 +325,7 @@ export function resolveTrackedFacadePluginId(params: { location: FacadeModuleLocation | null; sourceExtensionsRoot: string; resolutionKey: string; + env?: NodeJS.ProcessEnv; }): string { return resolveBundledPluginManifestRecord(params)?.id ?? params.dirName; } @@ -322,6 +336,7 @@ export function resolveBundledPluginPublicSurfaceAccess(params: { location: FacadeModuleLocation | null; sourceExtensionsRoot: string; resolutionKey: string; + env?: NodeJS.ProcessEnv; }): { allowed: boolean; pluginId?: string; reason?: string } { const cached = cachedFacadePublicSurfaceAccessByKey.get(params.resolutionKey); if (cached) { @@ -410,6 +425,7 @@ export function resolveActivatedBundledPluginPublicSurfaceAccessOrThrow(params: location: FacadeModuleLocation | null; sourceExtensionsRoot: string; resolutionKey: string; + env?: NodeJS.ProcessEnv; }) { const access = resolveBundledPluginPublicSurfaceAccess(params); if (!access.allowed) { diff --git a/src/plugin-sdk/facade-loader.ts b/src/plugin-sdk/facade-loader.ts index e6ba89332e7..1e51b1c272a 100644 --- a/src/plugin-sdk/facade-loader.ts +++ b/src/plugin-sdk/facade-loader.ts @@ -52,16 +52,21 @@ function getOpenClawPackageRoot() { return cachedOpenClawPackageRoot; } -function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string }): string { - const bundledPluginsDir = resolveBundledPluginsDir(); +function createFacadeResolutionKey(params: { + dirName: string; + artifactBasename: string; + env?: NodeJS.ProcessEnv; +}): string { + const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); return `${params.dirName}::${params.artifactBasename}::${bundledPluginsDir ? path.resolve(bundledPluginsDir) : ""}`; } function resolveFacadeModuleLocationUncached(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): { modulePath: string; boundaryRoot: string } | null { - const bundledPluginsDir = resolveBundledPluginsDir(); + const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); const preferSource = !CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`); if (preferSource) { const modulePath = @@ -71,6 +76,7 @@ function resolveFacadeModuleLocationUncached(params: { }) ?? resolveBundledPluginPublicSurfacePath({ rootDir: getOpenClawPackageRoot(), + env: params.env, ...(bundledPluginsDir ? { bundledPluginsDir } : {}), dirName: params.dirName, artifactBasename: params.artifactBasename, @@ -88,6 +94,7 @@ function resolveFacadeModuleLocationUncached(params: { } const modulePath = resolveBundledPluginPublicSurfacePath({ rootDir: getOpenClawPackageRoot(), + env: params.env, ...(bundledPluginsDir ? { bundledPluginsDir } : {}), dirName: params.dirName, artifactBasename: params.artifactBasename, @@ -107,6 +114,7 @@ function resolveFacadeModuleLocationUncached(params: { function resolveFacadeModuleLocation(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): { modulePath: string; boundaryRoot: string } | null { const key = createFacadeResolutionKey(params); if (cachedFacadeModuleLocationsByKey.has(key)) { @@ -253,6 +261,7 @@ export function loadBundledPluginPublicSurfaceModuleSync(param dirName: string; artifactBasename: string; trackedPluginId?: string | (() => string); + env?: NodeJS.ProcessEnv; }): T { const location = resolveFacadeModuleLocation(params); if (!location) { diff --git a/src/plugin-sdk/facade-runtime.ts b/src/plugin-sdk/facade-runtime.ts index c257b066724..ae87087c820 100644 --- a/src/plugin-sdk/facade-runtime.ts +++ b/src/plugin-sdk/facade-runtime.ts @@ -42,8 +42,12 @@ const cachedFacadeModuleLocationsByKey = new Map< } | null >(); -function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string }): string { - const bundledPluginsDir = resolveBundledPluginsDir(); +function createFacadeResolutionKey(params: { + dirName: string; + artifactBasename: string; + env?: NodeJS.ProcessEnv; +}): string { + const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); return `${params.dirName}::${params.artifactBasename}::${bundledPluginsDir ? path.resolve(bundledPluginsDir) : ""}`; } @@ -81,6 +85,7 @@ function resolveRegistryPluginModuleLocationFromRegistry(params: { function resolveRegistryPluginModuleLocation(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): { modulePath: string; boundaryRoot: string } | null { return loadFacadeActivationCheckRuntime().resolveRegistryPluginModuleLocation({ ...params, @@ -91,8 +96,9 @@ function resolveRegistryPluginModuleLocation(params: { function resolveFacadeModuleLocationUncached(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): { modulePath: string; boundaryRoot: string } | null { - const bundledPluginsDir = resolveBundledPluginsDir(); + const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env); const preferSource = !CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`); if (preferSource) { const modulePath = @@ -102,6 +108,7 @@ function resolveFacadeModuleLocationUncached(params: { }) ?? resolveBundledPluginPublicSurfacePath({ rootDir: OPENCLAW_PACKAGE_ROOT, + env: params.env, ...(bundledPluginsDir ? { bundledPluginsDir } : {}), dirName: params.dirName, artifactBasename: params.artifactBasename, @@ -119,6 +126,7 @@ function resolveFacadeModuleLocationUncached(params: { } const modulePath = resolveBundledPluginPublicSurfacePath({ rootDir: OPENCLAW_PACKAGE_ROOT, + env: params.env, ...(bundledPluginsDir ? { bundledPluginsDir } : {}), dirName: params.dirName, artifactBasename: params.artifactBasename, @@ -138,6 +146,7 @@ function resolveFacadeModuleLocationUncached(params: { function resolveFacadeModuleLocation(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): { modulePath: string; boundaryRoot: string } | null { const key = createFacadeResolutionKey(params); if (cachedFacadeModuleLocationsByKey.has(key)) { @@ -151,6 +160,7 @@ function resolveFacadeModuleLocation(params: { type BundledPluginPublicSurfaceParams = { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }; type FacadeActivationCheckRuntimeModule = typeof import("./facade-activation-check.runtime.js"); @@ -252,6 +262,7 @@ export function loadBundledPluginPublicSurfaceModuleSync( export function canLoadActivatedBundledPluginPublicSurface(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): boolean { return loadFacadeActivationCheckRuntime().resolveBundledPluginPublicSurfaceAccess( buildFacadeActivationCheckParams(params), @@ -261,6 +272,7 @@ export function canLoadActivatedBundledPluginPublicSurface(params: { export function loadActivatedBundledPluginPublicSurfaceModuleSync(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): T { loadFacadeActivationCheckRuntime().resolveActivatedBundledPluginPublicSurfaceAccessOrThrow( buildFacadeActivationCheckParams(params), @@ -271,6 +283,7 @@ export function loadActivatedBundledPluginPublicSurfaceModuleSync(params: { dirName: string; artifactBasename: string; + env?: NodeJS.ProcessEnv; }): T | null { const access = loadFacadeActivationCheckRuntime().resolveBundledPluginPublicSurfaceAccess( buildFacadeActivationCheckParams(params), diff --git a/src/plugin-sdk/private-qa-bundled-env.ts b/src/plugin-sdk/private-qa-bundled-env.ts new file mode 100644 index 00000000000..7c77932dde4 --- /dev/null +++ b/src/plugin-sdk/private-qa-bundled-env.ts @@ -0,0 +1,31 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; + +export function resolvePrivateQaBundledPluginsEnv( + env: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv | undefined { + if (env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") { + return undefined; + } + const packageRoot = resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }); + if (!packageRoot) { + return undefined; + } + const sourceExtensionsDir = path.join(packageRoot, "extensions"); + if ( + !fs.existsSync(path.join(packageRoot, ".git")) || + !fs.existsSync(path.join(packageRoot, "src")) || + !fs.existsSync(sourceExtensionsDir) + ) { + return undefined; + } + return { + ...env, + OPENCLAW_BUNDLED_PLUGINS_DIR: sourceExtensionsDir, + }; +} diff --git a/src/plugin-sdk/qa-runner-runtime.test.ts b/src/plugin-sdk/qa-runner-runtime.test.ts index fe7dabbd2b8..be937ced849 100644 --- a/src/plugin-sdk/qa-runner-runtime.test.ts +++ b/src/plugin-sdk/qa-runner-runtime.test.ts @@ -1,20 +1,31 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; import type { Command } from "commander"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadPluginManifestRegistry = vi.hoisted(() => vi.fn()); const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); const tryLoadActivatedBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); +const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn()); vi.mock("../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry, })); +vi.mock("../infra/openclaw-root.js", () => ({ + resolveOpenClawPackageRootSync, +})); + vi.mock("./facade-runtime.js", () => ({ loadBundledPluginPublicSurfaceModuleSync, tryLoadActivatedBundledPluginPublicSurfaceModuleSync, })); describe("plugin-sdk qa-runner-runtime", () => { + const tempDirs: string[] = []; + const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + beforeEach(() => { loadPluginManifestRegistry.mockReset().mockReturnValue({ plugins: [], @@ -22,6 +33,19 @@ describe("plugin-sdk qa-runner-runtime", () => { }); loadBundledPluginPublicSurfaceModuleSync.mockReset(); tryLoadActivatedBundledPluginPublicSurfaceModuleSync.mockReset(); + resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null); + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalPrivateQaCli === undefined) { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + } else { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli; + } }); it("stays cold until runner discovery is requested", async () => { @@ -48,6 +72,34 @@ describe("plugin-sdk qa-runner-runtime", () => { }); }); + it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runtime-root-")); + tempDirs.push(sourceRoot); + fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot); + + const runtimeSurface = { + defaultQaRuntimeModelForMode: vi.fn(), + startQaLiveLaneGateway: vi.fn(), + }; + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue(runtimeSurface); + + const module = await import("./qa-runner-runtime.js"); + + expect(module.loadQaRuntimeModule()).toBe(runtimeSurface); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "qa-lab", + artifactBasename: "runtime-api.js", + env: expect.objectContaining({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"), + }), + }); + }); + it("reports the qa runtime as unavailable when the qa-lab surface is missing", async () => { loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { throw new Error("Unable to resolve bundled plugin public surface qa-lab/runtime-api.js"); @@ -125,6 +177,61 @@ describe("plugin-sdk qa-runner-runtime", () => { ]); }); + it("prefers the source bundled tree for private qa discovery in repo checkouts", async () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runner-root-")); + tempDirs.push(sourceRoot); + fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot); + + const register = vi.fn((qa: Command) => qa); + loadPluginManifestRegistry.mockReturnValue({ + plugins: [ + { + id: "qa-matrix", + origin: "bundled", + qaRunners: [{ commandName: "matrix" }], + rootDir: path.join(sourceRoot, "extensions", "qa-matrix"), + }, + ], + diagnostics: [], + }); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + qaRunnerCliRegistrations: [{ commandName: "matrix", register }], + }); + + const module = await import("./qa-runner-runtime.js"); + + expect(module.listQaRunnerCliContributions()).toEqual([ + { + pluginId: "qa-matrix", + commandName: "matrix", + status: "available", + registration: { + commandName: "matrix", + register, + }, + }, + ]); + expect(loadPluginManifestRegistry).toHaveBeenCalledWith({ + cache: true, + env: expect.objectContaining({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"), + }), + }); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "qa-matrix", + artifactBasename: "runtime-api.js", + env: expect.objectContaining({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"), + }), + }); + }); + it("fails fast when two plugins declare the same qa runner command", async () => { loadPluginManifestRegistry.mockReturnValue({ plugins: [ diff --git a/src/plugin-sdk/qa-runner-runtime.ts b/src/plugin-sdk/qa-runner-runtime.ts index 44c5a1a6c6e..7a149bdc39a 100644 --- a/src/plugin-sdk/qa-runner-runtime.ts +++ b/src/plugin-sdk/qa-runner-runtime.ts @@ -5,6 +5,7 @@ import { loadBundledPluginPublicSurfaceModuleSync, tryLoadActivatedBundledPluginPublicSurfaceModuleSync, } from "./facade-runtime.js"; +import { resolvePrivateQaBundledPluginsEnv } from "./private-qa-bundled-env.js"; export type QaRunnerCliRegistration = { commandName: string; @@ -53,9 +54,11 @@ function isMissingQaRuntimeError(error: unknown) { } export function loadQaRuntimeModule(): QaRuntimeSurface { + const env = resolvePrivateQaBundledPluginsEnv(); return loadBundledPluginPublicSurfaceModuleSync({ dirName: ["qa", "lab"].join("-"), artifactBasename: ["runtime-api", "js"].join("."), + ...(env ? { env } : {}), }); } @@ -71,12 +74,14 @@ export function isQaRuntimeAvailable(): boolean { } } -function listDeclaredQaRunnerPlugins(): Array< +function listDeclaredQaRunnerPlugins( + env: NodeJS.ProcessEnv | undefined = resolvePrivateQaBundledPluginsEnv(), +): Array< PluginManifestRecord & { qaRunners: NonNullable; } > { - return loadPluginManifestRegistry({ cache: true }) + return loadPluginManifestRegistry({ cache: true, ...(env ? { env } : {}) }) .plugins.filter( ( plugin, @@ -113,24 +118,30 @@ function indexRuntimeRegistrations( return registrationByCommandName; } -function loadQaRunnerRuntimeSurface(plugin: PluginManifestRecord): QaRunnerRuntimeSurface | null { +function loadQaRunnerRuntimeSurface( + plugin: PluginManifestRecord, + env?: NodeJS.ProcessEnv, +): QaRunnerRuntimeSurface | null { if (plugin.origin === "bundled") { return loadBundledPluginPublicSurfaceModuleSync({ dirName: plugin.id, artifactBasename: "runtime-api.js", + ...(env ? { env } : {}), }); } return tryLoadActivatedBundledPluginPublicSurfaceModuleSync({ dirName: plugin.id, artifactBasename: "runtime-api.js", + ...(env ? { env } : {}), }); } export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution[] { + const env = resolvePrivateQaBundledPluginsEnv(); const contributions = new Map(); - for (const plugin of listDeclaredQaRunnerPlugins()) { - const runtimeSurface = loadQaRunnerRuntimeSurface(plugin); + for (const plugin of listDeclaredQaRunnerPlugins(env)) { + const runtimeSurface = loadQaRunnerRuntimeSurface(plugin, env); const runtimeRegistrationByCommandName = runtimeSurface ? indexRuntimeRegistrations(plugin.id, runtimeSurface) : null; diff --git a/src/plugin-sdk/qa-runtime.test.ts b/src/plugin-sdk/qa-runtime.test.ts index cd871db9ff0..026cb142d78 100644 --- a/src/plugin-sdk/qa-runtime.test.ts +++ b/src/plugin-sdk/qa-runtime.test.ts @@ -1,14 +1,38 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); +const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn()); vi.mock("./facade-runtime.js", () => ({ loadBundledPluginPublicSurfaceModuleSync, })); +vi.mock("../infra/openclaw-root.js", () => ({ + resolveOpenClawPackageRootSync, +})); + describe("plugin-sdk qa-runtime", () => { + const tempDirs: string[] = []; + const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + beforeEach(() => { loadBundledPluginPublicSurfaceModuleSync.mockReset(); + resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null); + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + }); + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } + if (originalPrivateQaCli === undefined) { + delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI; + } else { + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli; + } }); it("stays cold until the runtime seam is used", async () => { @@ -35,6 +59,34 @@ describe("plugin-sdk qa-runtime", () => { }); }); + it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => { + const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runtime-root-")); + tempDirs.push(sourceRoot); + fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true }); + fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true }); + fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1"; + resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot); + + const runtimeSurface = { + defaultQaRuntimeModelForMode: vi.fn(), + startQaLiveLaneGateway: vi.fn(), + }; + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue(runtimeSurface); + + const module = await import("./qa-runtime.js"); + + expect(module.loadQaRuntimeModule()).toBe(runtimeSurface); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "qa-lab", + artifactBasename: "runtime-api.js", + env: expect.objectContaining({ + OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", + OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"), + }), + }); + }); + it("reports the runtime as unavailable when the qa-lab surface is missing", async () => { loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => { throw new Error("Unable to resolve bundled plugin public surface qa-lab/runtime-api.js"); diff --git a/src/plugin-sdk/qa-runtime.ts b/src/plugin-sdk/qa-runtime.ts index 4fabbfdd137..390a7755bfb 100644 --- a/src/plugin-sdk/qa-runtime.ts +++ b/src/plugin-sdk/qa-runtime.ts @@ -1,4 +1,5 @@ import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; +import { resolvePrivateQaBundledPluginsEnv } from "./private-qa-bundled-env.js"; type QaRuntimeSurface = { defaultQaRuntimeModelForMode: ( @@ -20,9 +21,11 @@ function isMissingQaRuntimeError(error: unknown) { } export function loadQaRuntimeModule(): QaRuntimeSurface { + const env = resolvePrivateQaBundledPluginsEnv(); return loadBundledPluginPublicSurfaceModuleSync({ dirName: "qa-lab", artifactBasename: "runtime-api.js", + ...(env ? { env } : {}), }); } diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 2c91cbdb61c..088395a64f3 100644 --- a/src/plugin-sdk/root-alias.cjs +++ b/src/plugin-sdk/root-alias.cjs @@ -106,8 +106,9 @@ function findDistChunkByPrefix(prefix) { function listPluginSdkExportedSubpaths() { const packageRoot = getPackageRoot(); - if (pluginSdkSubpathsCache.has(packageRoot)) { - return pluginSdkSubpathsCache.get(packageRoot); + const cacheKey = `${packageRoot}::privateQa=${process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1" ? "1" : "0"}`; + if (pluginSdkSubpathsCache.has(cacheKey)) { + return pluginSdkSubpathsCache.get(cacheKey); } let subpaths = []; @@ -123,20 +124,46 @@ function listPluginSdkExportedSubpaths() { subpaths = []; } - pluginSdkSubpathsCache.set(packageRoot, subpaths); + pluginSdkSubpathsCache.set(cacheKey, subpaths); return subpaths; } +function listPrivateLocalOnlyPluginSdkSubpaths() { + if (process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") { + return []; + } + try { + const raw = fs.readFileSync( + path.join(getPackageRoot(), "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + "utf8", + ); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter( + (subpath) => typeof subpath === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath), + ); + } catch { + return []; + } +} + +function listPluginSdkRootAliasSubpaths() { + const exportedSubpaths = listPluginSdkExportedSubpaths(); + return [...new Set([...exportedSubpaths, ...listPrivateLocalOnlyPluginSdkSubpaths()])].toSorted( + (left, right) => left.localeCompare(right), + ); +} + function buildPluginSdkAliasMap(useDist) { const packageRoot = getPackageRoot(); const pluginSdkDir = path.join(packageRoot, useDist ? "dist" : "src", "plugin-sdk"); const normalizeTarget = (target) => process.platform === "win32" ? target.replace(/\\/g, "/") : target; - const aliasMap = Object.fromEntries( - pluginSdkPackageNames.map((packageName) => [packageName, normalizeTarget(__filename)]), - ); + const aliasMap = {}; - for (const subpath of listPluginSdkExportedSubpaths()) { + for (const subpath of listPluginSdkRootAliasSubpaths()) { if (useDist) { const candidate = path.join(pluginSdkDir, `${subpath}.js`); if (fs.existsSync(candidate)) { @@ -158,6 +185,12 @@ function buildPluginSdkAliasMap(useDist) { } } + // Keep the bare root alias last so subpath aliases win under resolvers that + // perform prefix matching instead of exact-key lookup. + for (const packageName of pluginSdkPackageNames) { + aliasMap[packageName] = normalizeTarget(__filename); + } + return aliasMap; } diff --git a/src/plugins/contracts/plugin-sdk-root-alias.test.ts b/src/plugins/contracts/plugin-sdk-root-alias.test.ts index 599c373edf7..3e9647a9fb7 100644 --- a/src/plugins/contracts/plugin-sdk-root-alias.test.ts +++ b/src/plugins/contracts/plugin-sdk-root-alias.test.ts @@ -29,6 +29,7 @@ function loadRootAliasWithStubs(options?: { packageExports?: Record; platform?: string; existingPaths?: string[]; + privateLocalOnlySubpaths?: unknown; }) { let createJitiCalls = 0; let jitiLoadCalls = 0; @@ -61,13 +62,21 @@ function loadRootAliasWithStubs(options?: { } if (id === "node:fs") { return { - readFileSync: () => - JSON.stringify({ + readFileSync: (targetPath: string) => { + if ( + targetPath.endsWith( + path.join("scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + ) + ) { + return JSON.stringify(options?.privateLocalOnlySubpaths ?? []); + } + return JSON.stringify({ exports: { "./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" }, ...options?.packageExports, }, - }), + }); + }, existsSync: (targetPath: string) => { if (targetPath.endsWith(path.join("dist", "infra", "diagnostic-events.js"))) { return options?.distExists ?? false; @@ -298,17 +307,40 @@ describe("plugin-sdk root alias", () => { (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record, ); expect(aliasKeys).toEqual([ - "openclaw/plugin-sdk", - "@openclaw/plugin-sdk", "openclaw/plugin-sdk/alpha", "@openclaw/plugin-sdk/alpha", "openclaw/plugin-sdk/group-access", "@openclaw/plugin-sdk/group-access", "openclaw/plugin-sdk/zeta", "@openclaw/plugin-sdk/zeta", + "openclaw/plugin-sdk", + "@openclaw/plugin-sdk", ]); }); + it("ignores unsafe private local-only plugin-sdk subpaths in the CJS root alias", () => { + const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath))); + const lazyModule = loadRootAliasWithStubs({ + env: { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, + privateLocalOnlySubpaths: ["qa-lab", "../escape", "nested/path"], + existingPaths: [path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts")], + monolithicExports: { + slowHelper: (): string => "loaded", + }, + }); + + expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded"); + const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record; + expect(aliasMap["openclaw/plugin-sdk/qa-lab"]).toBe( + path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"), + ); + expect(aliasMap["@openclaw/plugin-sdk/qa-lab"]).toBe( + path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"), + ); + expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/../escape"); + expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/nested/path"); + }); + it("builds source plugin-sdk subpath aliases through the wider source extension family", () => { const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath))); const lazyModule = loadRootAliasWithStubs({ diff --git a/src/plugins/sdk-alias.test.ts b/src/plugins/sdk-alias.test.ts index cba0071797c..77fbaaeccce 100644 --- a/src/plugins/sdk-alias.test.ts +++ b/src/plugins/sdk-alias.test.ts @@ -98,6 +98,12 @@ function createPluginSdkAliasFixture(params?: { if (trustedRootIndicatorMode === "bin+marker") { fs.writeFileSync(path.join(root, "openclaw.mjs"), "export {};\n", "utf-8"); } + mkdirSafeDir(path.join(root, "scripts", "lib")); + fs.writeFileSync( + path.join(root, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + JSON.stringify(["qa-lab", "qa-runtime"], null, 2), + "utf-8", + ); fs.writeFileSync(srcFile, params?.srcBody ?? "export {};\n", "utf-8"); fs.writeFileSync(distFile, params?.distBody ?? "export {};\n", "utf-8"); return { root, srcFile, distFile }; @@ -518,6 +524,62 @@ describe("plugin sdk alias helpers", () => { expect(subpaths).toEqual(["compat", "core"]); }); + it("adds private qa plugin-sdk subpaths for trusted local checkouts when enabled", () => { + const fixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + }, + }); + fs.writeFileSync( + path.join(fixture.root, "src", "plugin-sdk", "qa-runtime.ts"), + "export const qaRuntime = true;\n", + "utf-8", + ); + fs.writeFileSync( + path.join(fixture.root, "dist", "plugin-sdk", "qa-lab.js"), + "export const qaLab = true;\n", + "utf-8", + ); + + const subpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () => + listPluginSdkExportedSubpaths({ + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + }), + ); + expect(subpaths).toEqual(["core", "qa-lab", "qa-runtime"]); + }); + + it("does not reuse a non-private cached subpath list after private qa gets enabled", () => { + const fixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + }, + }); + fs.writeFileSync( + path.join(fixture.root, "src", "plugin-sdk", "qa-runtime.ts"), + "export const qaRuntime = true;\n", + "utf-8", + ); + fs.writeFileSync( + path.join(fixture.root, "dist", "plugin-sdk", "qa-lab.js"), + "export const qaLab = true;\n", + "utf-8", + ); + + expect( + listPluginSdkExportedSubpaths({ + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + }), + ).toEqual(["core"]); + + const privateSubpaths = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" }, () => + listPluginSdkExportedSubpaths({ + modulePath: path.join(fixture.root, "src", "plugins", "loader.ts"), + }), + ); + expect(privateSubpaths).toEqual(["core", "qa-lab", "qa-runtime"]); + }); + it.each([ { name: "does not derive plugin-sdk subpaths from cwd fallback when package root is not an OpenClaw root", @@ -588,6 +650,38 @@ describe("plugin sdk alias helpers", () => { }); }); + it("adds private qa plugin-sdk aliases for source plugins when enabled", () => { + const fixture = createPluginSdkAliasFixture({ + packageExports: { + "./plugin-sdk/core": { default: "./dist/plugin-sdk/core.js" }, + }, + }); + const sourceRootAlias = path.join(fixture.root, "src", "plugin-sdk", "root-alias.cjs"); + const sourceQaRuntimePath = path.join(fixture.root, "src", "plugin-sdk", "qa-runtime.ts"); + const distQaLabPath = path.join(fixture.root, "dist", "plugin-sdk", "qa-lab.js"); + fs.writeFileSync(sourceRootAlias, "module.exports = {};\n", "utf-8"); + fs.writeFileSync(sourceQaRuntimePath, "export const qaRuntime = true;\n", "utf-8"); + fs.writeFileSync(distQaLabPath, "export const qaLab = true;\n", "utf-8"); + const sourcePluginEntry = writePluginEntry( + fixture.root, + bundledPluginFile("qa-matrix", "src/index.ts"), + ); + + const aliases = withEnv({ OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1", NODE_ENV: undefined }, () => + buildPluginLoaderAliasMap(sourcePluginEntry), + ); + + expect(fs.realpathSync(aliases["openclaw/plugin-sdk"] ?? "")).toBe( + fs.realpathSync(sourceRootAlias), + ); + expect(fs.realpathSync(aliases["openclaw/plugin-sdk/qa-runtime"] ?? "")).toBe( + fs.realpathSync(sourceQaRuntimePath), + ); + expect(fs.realpathSync(aliases["openclaw/plugin-sdk/qa-lab"] ?? "")).toBe( + fs.realpathSync(distQaLabPath), + ); + }); + it("applies explicit dist resolution to plugin-sdk subpath aliases too", () => { const { fixture, distRootAlias, distChannelRuntimePath } = createPluginSdkAliasTargetFixture(); const sourcePluginEntry = writePluginEntry( diff --git a/src/plugins/sdk-alias.ts b/src/plugins/sdk-alias.ts index 20eefa05661..0bf1b02ef37 100644 --- a/src/plugins/sdk-alias.ts +++ b/src/plugins/sdk-alias.ts @@ -261,6 +261,45 @@ const PLUGIN_SDK_SOURCE_CANDIDATE_EXTENSIONS = [ ".cjs", ] as const; +function readPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] { + try { + const raw = fs.readFileSync( + path.join(packageRoot, "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"), + "utf-8", + ); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((subpath): subpath is string => isSafePluginSdkSubpathSegment(subpath)); + } catch { + return []; + } +} + +function shouldIncludePrivateLocalOnlyPluginSdkSubpaths() { + return process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1"; +} + +function hasPluginSdkSubpathArtifact(packageRoot: string, subpath: string) { + const distPath = path.join(packageRoot, "dist", "plugin-sdk", `${subpath}.js`); + if (fs.existsSync(distPath)) { + return true; + } + return PLUGIN_SDK_SOURCE_CANDIDATE_EXTENSIONS.some((ext) => + fs.existsSync(path.join(packageRoot, "src", "plugin-sdk", `${subpath}${ext}`)), + ); +} + +function listPrivateLocalOnlyPluginSdkSubpaths(packageRoot: string): string[] { + if (!shouldIncludePrivateLocalOnlyPluginSdkSubpaths()) { + return []; + } + return readPrivateLocalOnlyPluginSdkSubpaths(packageRoot).filter((subpath) => + hasPluginSdkSubpathArtifact(packageRoot, subpath), + ); +} + export function listPluginSdkExportedSubpaths( params: { modulePath?: string; @@ -278,12 +317,18 @@ export function listPluginSdkExportedSubpaths( if (!packageRoot) { return []; } - const cached = cachedPluginSdkExportedSubpaths.get(packageRoot); + const cacheKey = `${packageRoot}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}`; + const cached = cachedPluginSdkExportedSubpaths.get(cacheKey); if (cached) { return cached; } - const subpaths = readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? []; - cachedPluginSdkExportedSubpaths.set(packageRoot, subpaths); + const subpaths = [ + ...new Set([ + ...(readPluginSdkSubpathsFromPackageRoot(packageRoot) ?? []), + ...listPrivateLocalOnlyPluginSdkSubpaths(packageRoot), + ]), + ].toSorted(); + cachedPluginSdkExportedSubpaths.set(cacheKey, subpaths); return subpaths; } @@ -309,7 +354,7 @@ export function resolvePluginSdkScopedAliasMap( isProduction: process.env.NODE_ENV === "production", pluginSdkResolution: params.pluginSdkResolution, }); - const cacheKey = `${packageRoot}::${orderedKinds.join(",")}`; + const cacheKey = `${packageRoot}::${orderedKinds.join(",")}::privateQa=${shouldIncludePrivateLocalOnlyPluginSdkSubpaths() ? "1" : "0"}`; const cached = cachedPluginSdkScopedAliasMaps.get(cacheKey); if (cached) { return cached;