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.
This commit is contained in:
Gustavo Madeira Santana
2026-04-15 19:15:26 -04:00
parent 489404d75e
commit 4d8b1da1fb
23 changed files with 1088 additions and 287 deletions

View File

@@ -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<PluginRuntime>("QA channel runtime not initialized");
createPluginRuntimeStore<PluginRuntime>({
pluginId: "qa-channel",
errorMessage: "QA channel runtime not initialized",
});
export { getQaChannelRuntime, setQaChannelRuntime };

View File

@@ -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<typeof parseStableSemverFloor>,
right: ReturnType<typeof parseStableSemverFloor>,
) {
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<string, ModelProviderConfig>;
}) {
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<string>();
const visitedPluginIds = new Set<string>();
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,
};
}

View File

@@ -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");

View File

@@ -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<string, ModelProviderConfig>;
}) {
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<string>();
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<typeof parseStableSemverFloor>,
right: ReturnType<typeof parseStableSemverFloor>,
) {
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<ReturnType<typeof startQaGatewayRpcClient>> | 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",

View File

@@ -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<void>((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,

View File

@@ -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");
});
});

View File

@@ -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<string> {
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,
};

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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<string | void> }>,
) => {
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",
},
],
});
});
});

View File

@@ -68,6 +68,7 @@ function getPathWithParent(
function createEvalContext(api: QaFlowApi, vars: QaFlowVars) {
return {
...api,
qaImport: (specifier: string) => import(specifier),
vars,
...vars,
};

View File

@@ -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,

View File

@@ -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

View File

@@ -0,0 +1,4 @@
[
"qa-lab",
"qa-runtime"
]

View File

@@ -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";

View File

@@ -3,6 +3,6 @@ export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boo
}
export function loadPrivateQaCliModule(): Promise<Record<string, unknown>> {
const specifier = ["../../plugin-sdk/", "qa", "-lab.js"].join("");
const specifier = "openclaw/plugin-sdk/qa-lab";
return import(specifier) as Promise<Record<string, unknown>>;
}

View File

@@ -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;

View File

@@ -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<typeof createExitedProcess>;
spawnSync?: (cmd: string, args: string[]) => { status: number; stdout: string };
env?: Record<string, string>;
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, {

View File

@@ -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: [

View File

@@ -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<PluginManifestRecord["qaRunners"]>;
}
> {
return loadPluginManifestRegistry({ cache: true })
const env = resolvePrivateQaRunnerManifestEnv();
return loadPluginManifestRegistry({ cache: true, ...(env ? { env } : {}) })
.plugins.filter(
(
plugin,

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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;