From 4d8b1da1fbcd65d091b604415a7ed86796f502e0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 15 Apr 2026 19:15:26 -0400 Subject: [PATCH] QA: fix private runtime source loading Fix the private QA wrapper and source-checkout runtime paths so qa-lab, qa-channel, and qa-matrix resolve their local-only SDK surfaces and staged bundled plugins reliably. This keeps private QA behavior local-only, restores source-runner discovery, and makes the isolated gateway runtime load the right plugin tree under Node. --- extensions/qa-channel/src/runtime.ts | 5 +- .../qa-lab/src/bundled-plugin-staging.ts | 314 ++++++++++++++++++ extensions/qa-lab/src/gateway-child.test.ts | 172 +++++++++- extensions/qa-lab/src/gateway-child.ts | 252 +------------- .../qa-lab/src/model-catalog.runtime.ts | 4 +- extensions/qa-lab/src/node-exec.test.ts | 41 +++ extensions/qa-lab/src/node-exec.ts | 64 ++++ extensions/qa-lab/src/qa-channel-transport.ts | 1 + .../qa-lab/src/qa-transport-registry.ts | 45 ++- .../qa-lab/src/scenario-flow-runner.test.ts | 76 +++++ extensions/qa-lab/src/scenario-flow-runner.ts | 1 + qa/scenarios/bundled-plugin-skill-runtime.md | 4 +- qa/scenarios/session-memory-ranking.md | 15 +- ...lugin-sdk-private-local-only-subpaths.json | 4 + scripts/run-node.mjs | 2 +- src/cli/program/private-qa-cli.ts | 2 +- src/cli/program/register.subclis.test.ts | 2 +- src/infra/run-node.test.ts | 72 ++++ src/plugin-sdk/qa-runner-runtime.test.ts | 73 +++- src/plugin-sdk/qa-runner-runtime.ts | 34 +- src/plugin-sdk/root-alias.cjs | 45 ++- src/plugins/sdk-alias.test.ts | 94 ++++++ src/plugins/sdk-alias.ts | 53 ++- 23 files changed, 1088 insertions(+), 287 deletions(-) create mode 100644 extensions/qa-lab/src/bundled-plugin-staging.ts create mode 100644 extensions/qa-lab/src/node-exec.test.ts create mode 100644 extensions/qa-lab/src/node-exec.ts create mode 100644 extensions/qa-lab/src/scenario-flow-runner.test.ts create mode 100644 scripts/lib/plugin-sdk-private-local-only-subpaths.json 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..3b5018dec64 --- /dev/null +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -0,0 +1,314 @@ +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"; + +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 }) { + 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); + 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"; +} + +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 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: entry.isDirectory(), + }); + } +} + +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 sourceTreeRoot = path.join(params.repoRoot, stagedTreeName); + const stagedTreeRoot = path.join(stagedRoot, stagedTreeName); + await fs.mkdir(stagedTreeRoot, { recursive: true }); + if (existsSync(sourceTreeRoot)) { + for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) { + if (entry.name === "extensions") { + continue; + } + await symlinkQaStagedDirEntry({ + sourcePath: path.join(sourceTreeRoot, entry.name), + targetPath: path.join(stagedTreeRoot, entry.name), + directory: entry.isDirectory(), + }); + } + } + 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..16fd3ce680d 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -2,6 +2,7 @@ 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"; @@ -624,7 +625,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,9 +647,33 @@ 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"), ); }); @@ -657,10 +682,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 +753,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 +784,94 @@ describe("qa bundled plugin dir", () => { ).resolves.toBeTruthy(); }); + 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 }); + }); + 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", + ); + await mkdir(path.join(repoRoot, "node_modules", "fake-dep"), { recursive: true }); + await writeFile( + path.join(repoRoot, "node_modules", "fake-dep", "package.json"), + JSON.stringify({ name: "fake-dep", type: "module" }, null, 2), + "utf8", + ); + await writeFile( + path.join(repoRoot, "node_modules", "fake-dep", "index.js"), + 'export const marker = "ok";\n', + "utf8", + ); + 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", + }); + }); + 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 +1019,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 +1051,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..80ed9dab005 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; @@ -1051,6 +820,7 @@ export async function startQaGatewayChild(params: { let rpcClient: Awaited> | null = null; let stagedBundledPluginsRoot: string | null = null; let env: NodeJS.ProcessEnv | null = null; + const nodeExecPath = await resolveQaNodeExecPath(); try { for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) { @@ -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.ts b/extensions/qa-lab/src/qa-transport-registry.ts index 6f1050c6403..0ff1e5d7299 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) as QaTransportId; + if (transportId in QA_TRANSPORT_REGISTRY) { + return transportId; + } + 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..4dbcc159061 100644 --- a/scripts/run-node.mjs +++ b/scripts/run-node.mjs @@ -397,7 +397,7 @@ 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"); 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.ts b/src/cli/program/private-qa-cli.ts index e5f425d9a39..5bedec7a8be 100644 --- a/src/cli/program/private-qa-cli.ts +++ b/src/cli/program/private-qa-cli.ts @@ -3,6 +3,6 @@ export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boo } export function loadPrivateQaCliModule(): Promise> { - const specifier = ["../../plugin-sdk/", "qa", "-lab.js"].join(""); + const specifier = "openclaw/plugin-sdk/qa-lab"; return import(specifier) as Promise>; } diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 594714aff44..94428cb7646 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -37,7 +37,7 @@ 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("openclaw/plugin-sdk/qa-lab", () => ({ registerQaLabCli })); describe("registerSubCliCommands", () => { const originalArgv = process.argv; diff --git a/src/infra/run-node.test.ts b/src/infra/run-node.test.ts index 87e21acae6b..dd7cecca694 100644 --- a/src/infra/run-node.test.ts +++ b/src/infra/run-node.test.ts @@ -19,6 +19,7 @@ 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 EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts"); const EXTENSION_MANIFEST = bundledPluginFile("demo", "openclaw.plugin.json"); const EXTENSION_PACKAGE = bundledPluginFile("demo", "package.json"); @@ -190,6 +191,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 +342,54 @@ 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", + }, + 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([ + [process.execPath, "openclaw.mjs", "qa", "suite", "--transport", "qa-channel", "--provider-mode", "mock-openai"], + ]); + }); + }); + + it("rebuilds private QA commands when the QA CLI facade is missing", async () => { + await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => { + await setupTrackedProject(tmp, { + files: { + [ROOT_SRC]: "export const value = 1;\n", + }, + oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE], + 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/qa-runner-runtime.test.ts b/src/plugin-sdk/qa-runner-runtime.test.ts index fe7dabbd2b8..270415d3141 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 () => { @@ -125,6 +149,53 @@ 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"), + }), + }); + }); + 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..06d942009b9 100644 --- a/src/plugin-sdk/qa-runner-runtime.ts +++ b/src/plugin-sdk/qa-runner-runtime.ts @@ -1,4 +1,7 @@ +import fs from "node:fs"; +import path from "node:path"; import type { Command } from "commander"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js"; import { @@ -71,12 +74,41 @@ export function isQaRuntimeAvailable(): boolean { } } +function resolvePrivateQaRunnerManifestEnv( + 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, + }; +} + function listDeclaredQaRunnerPlugins(): Array< PluginManifestRecord & { qaRunners: NonNullable; } > { - return loadPluginManifestRegistry({ cache: true }) + const env = resolvePrivateQaRunnerManifestEnv(); + return loadPluginManifestRegistry({ cache: true, ...(env ? { env } : {}) }) .plugins.filter( ( plugin, diff --git a/src/plugin-sdk/root-alias.cjs b/src/plugin-sdk/root-alias.cjs index 2c91cbdb61c..9c2a5c30794 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,44 @@ 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"); + } 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 +183,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/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;