QA: fix private runtime source loading (#67428)

Merged via squash.

Prepared head SHA: b8bf2b6be6
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-15 21:59:16 -04:00
committed by GitHub
parent 489404d75e
commit d5933af80b
33 changed files with 1728 additions and 313 deletions

View File

@@ -23,6 +23,7 @@ Docs: https://docs.openclaw.ai
- Agents/local models: clarify low-context preflight hints for self-hosted models, point config-backed caps at the relevant OpenClaw setting, and stop suggesting larger models when `agents.defaults.contextTokens` is the real limit. (#66236) Thanks @ImLukeF.
- Dreaming/memory-core: change the default `dreaming.storage.mode` from `inline` to `separate` so Dreaming phase blocks (`## Light Sleep`, `## REM Sleep`) land in `memory/dreaming/{phase}/YYYY-MM-DD.md` instead of being injected into `memory/YYYY-MM-DD.md`. Daily memory files no longer get dominated by structured candidate output, and the daily-ingestion scanner that already strips dream marker blocks no longer has to compete with hundreds of phase-block lines on every run. Operators who want the previous behavior can opt in by setting `plugins.entries.memory-core.config.dreaming.storage.mode: "inline"`. (#66412) Thanks @mjamiv.
- Control UI/Overview: fix false-positive "missing" alerts on the Model Auth status card for aliased providers, env-backed OAuth with auth.profiles, and unresolvable env SecretRefs. (#67253) Thanks @omarshahine.
- QA/private runtime: keep the private QA CLI on the local plugin-sdk seam and preserve staged `dist-runtime` root chunks when isolated QA staging mixes built plugin trees. (#67428) thanks @gumadeiras.
## 2026.4.15-beta.1

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,417 @@
import { existsSync } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
const QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS = Object.freeze([
"image-generation-core",
"media-understanding-core",
"speech-core",
]);
const QA_OPENAI_PLUGIN_ID = "openai";
const QA_BUNDLED_PLUGIN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
function assertSafeQaBundledPluginId(pluginId: string) {
if (!QA_BUNDLED_PLUGIN_ID_PATTERN.test(pluginId)) {
throw new Error(`invalid QA bundled plugin id: ${pluginId}`);
}
}
function parseStableSemverFloor(value: string | undefined) {
if (!value) {
return null;
}
const match = value.trim().match(/(\d+)\.(\d+)\.(\d+)/);
if (!match) {
return null;
}
return {
major: Number.parseInt(match[1] ?? "", 10),
minor: Number.parseInt(match[2] ?? "", 10),
patch: Number.parseInt(match[3] ?? "", 10),
label: `${match[1]}.${match[2]}.${match[3]}`,
};
}
function compareSemverFloors(
left: ReturnType<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 }) {
assertSafeQaBundledPluginId(params.pluginId);
const candidates = [
path.join(params.repoRoot, "dist", "extensions", params.pluginId),
path.join(params.repoRoot, "dist-runtime", "extensions", params.pluginId),
path.join(params.repoRoot, "extensions", params.pluginId),
];
for (const candidate of candidates) {
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
function resolveQaBundledPluginScanRoots(repoRoot: string) {
return [
path.join(repoRoot, "dist", "extensions"),
path.join(repoRoot, "dist-runtime", "extensions"),
path.join(repoRoot, "extensions"),
].filter((candidate, index, all) => existsSync(candidate) && all.indexOf(candidate) === index);
}
export async function resolveQaOwnerPluginIdsForProviderIds(params: {
repoRoot: string;
providerIds: readonly string[];
providerConfigs?: Record<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.map((pluginId) => {
assertSafeQaBundledPluginId(pluginId);
return pluginId;
}),
);
for (const pluginId of QA_ALWAYS_STAGE_RUNTIME_PLUGIN_IDS) {
if (
resolveQaBundledPluginSourceDir({
repoRoot: params.repoRoot,
pluginId,
})
) {
pluginIds.add(pluginId);
}
}
return [...pluginIds];
}
function resolveQaStagedBundledTreeName(repoRoot: string) {
if (existsSync(path.join(repoRoot, "dist"))) {
return "dist";
}
if (existsSync(path.join(repoRoot, "dist-runtime"))) {
return "dist-runtime";
}
return "dist";
}
function resolveQaBuiltBundledPluginTreeRoot(params: { repoRoot: string; sourceDir: string }) {
const sourceDir = path.resolve(params.sourceDir);
for (const treeName of ["dist", "dist-runtime"] as const) {
const extensionsRoot = path.join(params.repoRoot, treeName, "extensions");
const relativeSourceDir = path.relative(extensionsRoot, sourceDir);
if (
relativeSourceDir.length > 0 &&
!relativeSourceDir.startsWith("..") &&
!path.isAbsolute(relativeSourceDir)
) {
return path.join(params.repoRoot, treeName);
}
}
return null;
}
async function symlinkQaStagedDirEntry(params: {
sourcePath: string;
targetPath: string;
directory?: boolean;
}) {
await fs.symlink(
params.sourcePath,
params.targetPath,
params.directory ? (process.platform === "win32" ? "junction" : "dir") : "file",
);
}
async function resolveQaStagedDirEntryDirectory(params: {
sourcePath: string;
entry?: {
isDirectory(): boolean;
isSymbolicLink(): boolean;
};
}) {
if (params.entry?.isDirectory()) {
return true;
}
if (params.entry?.isSymbolicLink()) {
return (await fs.stat(params.sourcePath)).isDirectory();
}
if (params.entry) {
return false;
}
return (await fs.lstat(params.sourcePath)).isDirectory();
}
async function seedQaStagedNodeModules(params: { repoRoot: string; stagedRoot: string }) {
const sourceNodeModulesDir = path.join(params.repoRoot, "node_modules");
if (!existsSync(sourceNodeModulesDir)) {
return;
}
const stagedNodeModulesDir = path.join(params.stagedRoot, "node_modules");
await fs.mkdir(stagedNodeModulesDir, { recursive: true });
for (const entry of await fs.readdir(sourceNodeModulesDir, { withFileTypes: true })) {
if (entry.name === "openclaw") {
continue;
}
await symlinkQaStagedDirEntry({
sourcePath: path.join(sourceNodeModulesDir, entry.name),
targetPath: path.join(stagedNodeModulesDir, entry.name),
directory: await resolveQaStagedDirEntryDirectory({
sourcePath: path.join(sourceNodeModulesDir, entry.name),
entry,
}),
});
}
}
function collectQaBuiltTreeRoots(params: {
repoRoot: string;
stagedPluginIds: readonly string[];
stagedTreeName: string;
}) {
const treeRoots = new Set<string>();
treeRoots.add(path.join(params.repoRoot, params.stagedTreeName));
for (const pluginId of params.stagedPluginIds) {
const sourceDir = resolveQaBundledPluginSourceDir({
repoRoot: params.repoRoot,
pluginId,
});
if (!sourceDir) {
continue;
}
const builtTreeRoot = resolveQaBuiltBundledPluginTreeRoot({
repoRoot: params.repoRoot,
sourceDir,
});
if (builtTreeRoot) {
treeRoots.add(builtTreeRoot);
}
}
return [...treeRoots];
}
async function seedQaStagedBuiltTreeRoots(params: {
stagedTreeRoot: string;
sourceTreeRoots: readonly string[];
}) {
for (const sourceTreeRoot of params.sourceTreeRoots) {
if (!existsSync(sourceTreeRoot)) {
continue;
}
for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
}
const targetPath = path.join(params.stagedTreeRoot, entry.name);
if (existsSync(targetPath)) {
continue;
}
await symlinkQaStagedDirEntry({
sourcePath: path.join(sourceTreeRoot, entry.name),
targetPath,
directory: await resolveQaStagedDirEntryDirectory({
sourcePath: path.join(sourceTreeRoot, entry.name),
entry,
}),
});
}
}
}
export async function resolveQaRuntimeHostVersion(params: {
repoRoot: string;
allowedPluginIds: readonly string[];
}) {
const rootPackageRaw = await fs.readFile(path.join(params.repoRoot, "package.json"), "utf8");
const rootPackage = JSON.parse(rootPackageRaw) as { version?: string };
let selected = parseStableSemverFloor(rootPackage.version);
const stagedPluginIds = collectQaBundledPluginIds({
repoRoot: params.repoRoot,
allowedPluginIds: params.allowedPluginIds,
});
for (const pluginId of stagedPluginIds) {
const sourceDir = resolveQaBundledPluginSourceDir({
repoRoot: params.repoRoot,
pluginId,
});
if (!sourceDir) {
continue;
}
const packagePath = path.join(sourceDir, "package.json");
if (!existsSync(packagePath)) {
continue;
}
const packageRaw = await fs.readFile(packagePath, "utf8");
const packageJson = JSON.parse(packageRaw) as {
openclaw?: {
install?: {
minHostVersion?: string;
};
};
};
const candidate = parseStableSemverFloor(packageJson.openclaw?.install?.minHostVersion);
if (compareSemverFloors(candidate, selected) > 0) {
selected = candidate;
}
}
return selected?.label;
}
export async function createQaBundledPluginsDir(params: {
repoRoot: string;
tempRoot: string;
allowedPluginIds: readonly string[];
}) {
const stagedPluginIds = collectQaBundledPluginIds({
repoRoot: params.repoRoot,
allowedPluginIds: params.allowedPluginIds,
});
const stagedRoot = path.join(
params.repoRoot,
".artifacts",
"qa-runtime",
path.basename(params.tempRoot),
);
await fs.rm(stagedRoot, { recursive: true, force: true });
await fs.mkdir(stagedRoot, { recursive: true });
await fs.copyFile(
path.join(params.repoRoot, "package.json"),
path.join(stagedRoot, "package.json"),
);
await seedQaStagedNodeModules({
repoRoot: params.repoRoot,
stagedRoot,
});
const stagedOpenClawPackageDir = path.join(stagedRoot, "node_modules", "openclaw");
await fs.mkdir(stagedOpenClawPackageDir, { recursive: true });
await fs.copyFile(
path.join(params.repoRoot, "package.json"),
path.join(stagedOpenClawPackageDir, "package.json"),
);
const stagedTreeName = resolveQaStagedBundledTreeName(params.repoRoot);
const stagedTreeRoot = path.join(stagedRoot, stagedTreeName);
await fs.mkdir(stagedTreeRoot, { recursive: true });
await seedQaStagedBuiltTreeRoots({
stagedTreeRoot,
sourceTreeRoots: collectQaBuiltTreeRoots({
repoRoot: params.repoRoot,
stagedPluginIds,
stagedTreeName,
}),
});
if (stagedTreeName === "dist-runtime" && !existsSync(path.join(stagedRoot, "dist"))) {
const repoDistDir = path.join(params.repoRoot, "dist");
const stagedDistTarget = existsSync(repoDistDir) ? repoDistDir : stagedTreeRoot;
await symlinkQaStagedDirEntry({
sourcePath: stagedDistTarget,
targetPath: path.join(stagedRoot, "dist"),
directory: true,
});
}
const bundledPluginsDir = path.join(stagedTreeRoot, "extensions");
await fs.mkdir(bundledPluginsDir, { recursive: true });
for (const pluginId of stagedPluginIds) {
const sourceDir = resolveQaBundledPluginSourceDir({
repoRoot: params.repoRoot,
pluginId,
});
if (!sourceDir) {
throw new Error(`qa bundled plugin not found: ${pluginId}`);
}
await fs.cp(sourceDir, path.join(bundledPluginsDir, pluginId), { recursive: true });
}
await symlinkQaStagedDirEntry({
sourcePath: path.join(stagedRoot, "dist"),
targetPath: path.join(stagedOpenClawPackageDir, "dist"),
directory: true,
});
return {
bundledPluginsDir,
stagedRoot,
};
}

View File

@@ -2,19 +2,39 @@ import { spawn } from "node:child_process";
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it, vi } from "vitest";
import { __testing, buildQaRuntimeEnv, resolveQaControlUiRoot } from "./gateway-child.js";
import {
__testing,
buildQaRuntimeEnv,
resolveQaControlUiRoot,
startQaGatewayChild,
} from "./gateway-child.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
const resolveQaNodeExecPathMock = vi.hoisted(() => vi.fn(async () => process.execPath));
const qaTempPathState = vi.hoisted(() => ({
preferredTmpDir: process.env.TMPDIR || "/tmp",
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
vi.mock("openclaw/plugin-sdk/temp-path", () => ({
resolvePreferredOpenClawTmpDir: () => qaTempPathState.preferredTmpDir,
}));
vi.mock("./node-exec.js", () => ({
resolveQaNodeExecPath: resolveQaNodeExecPathMock,
}));
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
fetchWithSsrFGuardMock.mockReset();
resolveQaNodeExecPathMock.mockReset();
qaTempPathState.preferredTmpDir = process.env.TMPDIR || "/tmp";
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
@@ -36,6 +56,28 @@ function createParams(baseEnv?: NodeJS.ProcessEnv) {
}
describe("buildQaRuntimeEnv", () => {
it("cleans up temp QA gateway roots when node path resolution fails before startup", async () => {
const tempParent = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-node-exec-fail-"));
cleanups.push(async () => {
await rm(tempParent, { recursive: true, force: true });
});
qaTempPathState.preferredTmpDir = tempParent;
resolveQaNodeExecPathMock.mockRejectedValueOnce(new Error("node missing"));
await expect(
startQaGatewayChild({
repoRoot: process.cwd(),
transport: {
requiredPluginIds: [],
createGatewayConfig: () => ({}),
},
transportBaseUrl: "http://127.0.0.1:43123",
}),
).rejects.toThrow("node missing");
await expect(readdir(tempParent)).resolves.toEqual([]);
});
it("keeps the slow-reply QA opt-out enabled under fast mode", () => {
const env = buildQaRuntimeEnv({
...createParams(),
@@ -624,7 +666,7 @@ describe("resolveQaControlUiRoot", () => {
});
describe("qa bundled plugin dir", () => {
it("prefers the built bundled plugin tree when present", async () => {
it("prefers a built bundled plugin when present", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-root-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
@@ -646,10 +688,30 @@ describe("qa bundled plugin dir", () => {
"utf8",
);
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8");
expect(__testing.resolveQaBundledPluginsSourceRoot(repoRoot)).toBe(
path.join(repoRoot, "dist", "extensions"),
);
expect(
__testing.resolveQaBundledPluginSourceDir({
repoRoot,
pluginId: "qa-channel",
}),
).toBe(path.join(repoRoot, "dist", "extensions", "qa-channel"));
});
it("falls back to the source bundled plugin when no built copy exists", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-root-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
await writeFile(path.join(repoRoot, "extensions", "qa-channel", "package.json"), "{}", "utf8");
expect(
__testing.resolveQaBundledPluginSourceDir({
repoRoot,
pluginId: "qa-channel",
}),
).toBe(path.join(repoRoot, "extensions", "qa-channel"));
});
it("creates a scoped bundled plugin tree for allowed plugins plus always-allowed runtime facades", async () => {
@@ -657,10 +719,47 @@ describe("qa bundled plugin dir", () => {
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify(
{
name: "openclaw",
type: "module",
exports: {
"./plugin-sdk/account-id": {
default: "./dist/plugin-sdk/account-id.js",
},
},
},
null,
2,
),
"utf8",
);
await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true });
await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true });
await mkdir(path.join(repoRoot, "dist", "extensions", "speech-core"), { recursive: true });
await mkdir(path.join(repoRoot, "dist", "extensions", "unused-plugin"), { recursive: true });
await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true });
await writeFile(
path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"),
"export const normalizeAccountId = (value) => value.toLowerCase();\n",
"utf8",
);
await writeFile(
path.join(repoRoot, "dist", "extensions", "qa-channel", "package.json"),
JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2),
"utf8",
);
await writeFile(
path.join(repoRoot, "dist", "extensions", "qa-channel", "index.js"),
[
'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";',
'export const accountId = normalizeAccountId("QA");',
"",
].join("\n"),
"utf8",
);
await writeFile(path.join(repoRoot, "dist", "shared-chunk-abc123.js"), "export {};\n", "utf8");
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-target-"));
cleanups.push(async () => {
@@ -691,6 +790,20 @@ describe("qa bundled plugin dir", () => {
expect(stagedRoot).toBe(
path.join(repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot)),
);
expect(stagedRoot).not.toBeNull();
if (!stagedRoot) {
throw new Error("expected staged runtime root");
}
await expect(readFile(path.join(stagedRoot, "package.json"), "utf8")).resolves.toContain(
'"name": "openclaw"',
);
await expect(
import(
`${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.js")).href}?t=${Date.now()}`
),
).resolves.toMatchObject({
accountId: "qa",
});
expect((await lstat(path.join(bundledPluginsDir, "qa-channel"))).isDirectory()).toBe(true);
expect((await lstat(path.join(bundledPluginsDir, "memory-core"))).isDirectory()).toBe(true);
expect((await lstat(path.join(bundledPluginsDir, "speech-core"))).isDirectory()).toBe(true);
@@ -708,6 +821,209 @@ describe("qa bundled plugin dir", () => {
).resolves.toBeTruthy();
});
it("preserves dist-runtime-only root chunks when dist also exists", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-runtime-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf8",
);
await mkdir(path.join(repoRoot, "dist"), { recursive: true });
await writeFile(
path.join(repoRoot, "dist", "shared-dist.js"),
'export const dist = "dist";\n',
"utf8",
);
await mkdir(path.join(repoRoot, "dist-runtime", "extensions", "runtime-only"), {
recursive: true,
});
await writeFile(
path.join(repoRoot, "dist-runtime", "runtime-chunk.js"),
'export const marker = "runtime";\n',
"utf8",
);
await writeFile(
path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "package.json"),
JSON.stringify({ name: "@openclaw/runtime-only", type: "module" }, null, 2),
"utf8",
);
await writeFile(
path.join(repoRoot, "dist-runtime", "extensions", "runtime-only", "index.js"),
['import { marker } from "../../runtime-chunk.js";', "export { marker };", ""].join("\n"),
"utf8",
);
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-mixed-target-"));
cleanups.push(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
const { bundledPluginsDir } = await __testing.createQaBundledPluginsDir({
repoRoot,
tempRoot,
allowedPluginIds: ["runtime-only"],
});
expect(bundledPluginsDir).toBe(
path.join(
repoRoot,
".artifacts",
"qa-runtime",
path.basename(tempRoot),
"dist",
"extensions",
),
);
await expect(
import(
`${pathToFileURL(path.join(bundledPluginsDir, "runtime-only", "index.js")).href}?t=${Date.now()}`
),
).resolves.toMatchObject({
marker: "runtime",
});
await expect(
lstat(
path.join(
repoRoot,
".artifacts",
"qa-runtime",
path.basename(tempRoot),
"dist",
"runtime-chunk.js",
),
),
).resolves.toBeTruthy();
});
it("rejects invalid bundled plugin ids before staging paths are built", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-id-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
await writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify({ name: "openclaw", type: "module" }, null, 2),
"utf8",
);
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-invalid-target-"));
cleanups.push(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
await expect(
__testing.createQaBundledPluginsDir({
repoRoot,
tempRoot,
allowedPluginIds: ["../escape"],
}),
).rejects.toThrow("invalid QA bundled plugin id: ../escape");
});
it("stages source-only bundled plugins into a repo-like runtime root with node_modules", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-stage-"));
cleanups.push(async () => {
await rm(repoRoot, { recursive: true, force: true });
});
const fakeDepStoreRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-store-"));
cleanups.push(async () => {
await rm(fakeDepStoreRoot, { recursive: true, force: true });
});
await writeFile(
path.join(repoRoot, "package.json"),
JSON.stringify(
{
name: "openclaw",
type: "module",
exports: {
"./plugin-sdk/account-id": {
default: "./dist/plugin-sdk/account-id.js",
},
},
},
null,
2,
),
"utf8",
);
await mkdir(path.join(repoRoot, "dist", "plugin-sdk"), { recursive: true });
await writeFile(
path.join(repoRoot, "dist", "plugin-sdk", "account-id.js"),
"export const normalizeAccountId = (value) => value.toLowerCase();\n",
"utf8",
);
await mkdir(path.join(repoRoot, "extensions", "qa-channel"), { recursive: true });
await writeFile(
path.join(repoRoot, "extensions", "qa-channel", "package.json"),
JSON.stringify({ name: "@openclaw/qa-channel", type: "module" }, null, 2),
"utf8",
);
await writeFile(
path.join(repoRoot, "extensions", "qa-channel", "index.ts"),
[
'import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";',
'import { marker } from "fake-dep";',
'export const accountId = `${normalizeAccountId("QA")}:${marker}`;',
"",
].join("\n"),
"utf8",
);
const fakeDepPackageDir = path.join(fakeDepStoreRoot, "fake-dep");
await mkdir(fakeDepPackageDir, { recursive: true });
await writeFile(
path.join(fakeDepPackageDir, "package.json"),
JSON.stringify({ name: "fake-dep", type: "module" }, null, 2),
"utf8",
);
await writeFile(
path.join(fakeDepPackageDir, "index.js"),
'export const marker = "ok";\n',
"utf8",
);
await mkdir(path.join(repoRoot, "node_modules"), { recursive: true });
await symlink(fakeDepPackageDir, path.join(repoRoot, "node_modules", "fake-dep"), "dir");
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-source-target-"));
cleanups.push(async () => {
await rm(tempRoot, { recursive: true, force: true });
});
const { bundledPluginsDir, stagedRoot } = await __testing.createQaBundledPluginsDir({
repoRoot,
tempRoot,
allowedPluginIds: ["qa-channel"],
});
expect(bundledPluginsDir).toBe(
path.join(
repoRoot,
".artifacts",
"qa-runtime",
path.basename(tempRoot),
"dist",
"extensions",
),
);
if (!stagedRoot) {
throw new Error("expected staged runtime root");
}
await expect(
import(
`${pathToFileURL(path.join(bundledPluginsDir, "qa-channel", "index.ts")).href}?t=${Date.now()}`
),
).resolves.toMatchObject({
accountId: "qa:ok",
});
await expect(
lstat(path.join(stagedRoot, "node_modules", "fake-dep")).then((stats) =>
stats.isSymbolicLink(),
),
).resolves.toBe(true);
await expect(
readFile(path.join(stagedRoot, "node_modules", "fake-dep", "index.js"), "utf8"),
).resolves.toContain('marker = "ok"');
});
it("maps cli backend provider ids to their owning bundled plugin ids", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-plugin-owner-"));
cleanups.push(async () => {
@@ -855,7 +1171,6 @@ describe("qa bundled plugin dir", () => {
await expect(
__testing.resolveQaRuntimeHostVersion({
repoRoot,
bundledPluginsSourceRoot: bundledRoot,
allowedPluginIds: ["memory-core", "qa-channel"],
}),
).resolves.toBe("2026.4.8");
@@ -888,7 +1203,6 @@ describe("qa bundled plugin dir", () => {
await expect(
__testing.resolveQaRuntimeHostVersion({
repoRoot,
bundledPluginsSourceRoot: bundledRoot,
allowedPluginIds: ["qa-channel"],
}),
).resolves.toBe("2026.4.9");

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;
@@ -1053,6 +822,7 @@ export async function startQaGatewayChild(params: {
let env: NodeJS.ProcessEnv | null = null;
try {
const nodeExecPath = await resolveQaNodeExecPath();
for (let attempt = 1; attempt <= QA_GATEWAY_CHILD_STARTUP_MAX_ATTEMPTS; attempt += 1) {
gatewayPort = await getFreePort();
baseUrl = `http://127.0.0.1:${gatewayPort}`;
@@ -1068,7 +838,6 @@ export async function startQaGatewayChild(params: {
);
},
);
const bundledPluginsSourceRoot = resolveQaBundledPluginsSourceRoot(params.repoRoot);
const { bundledPluginsDir, stagedRoot } = await createQaBundledPluginsDir({
repoRoot: params.repoRoot,
tempRoot,
@@ -1077,7 +846,6 @@ export async function startQaGatewayChild(params: {
stagedBundledPluginsRoot = stagedRoot;
const runtimeHostVersion = await resolveQaRuntimeHostVersion({
repoRoot: params.repoRoot,
bundledPluginsSourceRoot,
allowedPluginIds,
});
env = buildQaRuntimeEnv({
@@ -1105,7 +873,7 @@ export async function startQaGatewayChild(params: {
}
const attemptChild = spawn(
process.execPath,
nodeExecPath,
[
distEntryPath,
"gateway",

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

@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { normalizeQaTransportId } from "./qa-transport-registry.js";
describe("qa transport registry", () => {
it("rejects inherited prototype keys as unsupported transport ids", () => {
expect(() => normalizeQaTransportId("toString")).toThrow("unsupported QA transport: toString");
expect(() => normalizeQaTransportId("__proto__")).toThrow(
"unsupported QA transport: __proto__",
);
});
});

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;
if (Object.hasOwn(QA_TRANSPORT_REGISTRY, transportId)) {
return transportId as QaTransportId;
}
throw new Error(`unsupported QA transport: ${transportId}`);
}
export function createQaTransportAdapter(params: {
id: QaTransportId;
state: QaBusState;
}): QaTransportAdapter {
switch (params.id) {
case "qa-channel":
return createQaChannelTransport(params.state);
default: {
const unsupported: never = params.id;
throw new Error(`unsupported QA transport: ${String(unsupported)}`);
}
}
return QA_TRANSPORT_REGISTRY[params.id].create(params.state);
}
export function defaultQaSuiteConcurrencyForTransport(id: QaTransportId): number {
return QA_TRANSPORT_REGISTRY[id].defaultSuiteConcurrency;
}

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

@@ -210,8 +210,8 @@ export const resolveBuildRequirement = (deps) => {
}
if (
deps.env.OPENCLAW_BUILD_PRIVATE_QA === "1" &&
deps.privateQaDistEntry &&
statMtime(deps.privateQaDistEntry, deps.fs) == null
((deps.privateQaDistEntry && statMtime(deps.privateQaDistEntry, deps.fs) == null) ||
(deps.privateQaBundledCliEntry && statMtime(deps.privateQaBundledCliEntry, deps.fs) == null))
) {
return { shouldBuild: true, reason: "missing_private_qa_dist" };
}
@@ -397,7 +397,8 @@ export async function runNodeMain(params = {}) {
path: path.join(deps.cwd, sourceRoot),
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
deps.privateQaDistEntry = path.join(deps.distRoot, "extensions", "qa-lab", "cli.js");
deps.privateQaDistEntry = path.join(deps.distRoot, "plugin-sdk", "qa-lab.js");
deps.privateQaBundledCliEntry = path.join(deps.distRoot, "extensions", "qa-lab", "cli.js");
if (deps.args[0] === "qa") {
deps.env.OPENCLAW_BUILD_PRIVATE_QA = "1";
deps.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";

View File

@@ -0,0 +1,79 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { loadPrivateQaCliModule } from "./private-qa-cli.js";
describe("private-qa-cli", () => {
const tempDirs: string[] = [];
const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
if (originalPrivateQaCli === undefined) {
delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
} else {
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli;
}
});
it("loads the private QA CLI from a source checkout path", async () => {
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-source-"));
tempDirs.push(repoRoot);
const expectedPaths = new Set([
path.join(repoRoot, ".git"),
path.join(repoRoot, "src"),
path.join(repoRoot, "dist", "plugin-sdk", "qa-lab.js"),
]);
let importedSpecifier: string | undefined;
const importModule = vi.fn(async (specifier: string) => {
importedSpecifier = specifier;
return {
isQaLabCliAvailable: expect.any(Function),
registerQaLabCli: expect.any(Function),
};
});
const module = await loadPrivateQaCliModule({
importModule,
resolvePackageRootSync: () => repoRoot,
existsSync: (filePath) => typeof filePath === "string" && expectedPaths.has(filePath),
});
expect(importModule).toHaveBeenCalledTimes(1);
expect(importedSpecifier).toContain("/dist/plugin-sdk/qa-lab.js");
expect(module).toMatchObject({
isQaLabCliAvailable: expect.any(Function),
registerQaLabCli: expect.any(Function),
});
});
it("rejects non-source package roots even when private QA is enabled", async () => {
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-private-qa-"));
tempDirs.push(root);
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "openclaw" }), "utf8");
const importModule = vi.fn(async () => ({}));
expect(() =>
loadPrivateQaCliModule({
resolvePackageRootSync: () => root,
importModule,
}),
).toThrow("Private QA CLI is only available from an OpenClaw source checkout.");
expect(importModule).not.toHaveBeenCalled();
});
it("rejects when the private QA env flag is disabled", async () => {
delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
const importModule = vi.fn(async () => ({}));
expect(() => loadPrivateQaCliModule({ importModule })).toThrow(
"Private QA CLI is only available from an OpenClaw source checkout.",
);
expect(importModule).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,65 @@
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { resolveOpenClawPackageRootSync } from "../../infra/openclaw-root.js";
const PRIVATE_QA_DIST_RELATIVE_PATH = path.join("dist", "plugin-sdk", "qa-lab.js");
export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
return env.OPENCLAW_ENABLE_PRIVATE_QA_CLI === "1";
}
export function loadPrivateQaCliModule(): Promise<Record<string, unknown>> {
const specifier = ["../../plugin-sdk/", "qa", "-lab.js"].join("");
return import(specifier) as Promise<Record<string, unknown>>;
function resolvePrivateQaSourceModuleSpecifier(params?: {
env?: NodeJS.ProcessEnv;
cwd?: string;
argv1?: string;
moduleUrl?: string;
resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync;
existsSync?: typeof fs.existsSync;
}): string | null {
const env = params?.env ?? process.env;
if (!isPrivateQaCliEnabled(env)) {
return null;
}
const resolvePackageRootSync = params?.resolvePackageRootSync ?? resolveOpenClawPackageRootSync;
const packageRoot = resolvePackageRootSync({
argv1: params?.argv1 ?? process.argv[1],
cwd: params?.cwd ?? process.cwd(),
moduleUrl: params?.moduleUrl ?? import.meta.url,
});
if (!packageRoot) {
return null;
}
const existsSync = params?.existsSync ?? fs.existsSync;
const sourceModulePath = path.join(packageRoot, PRIVATE_QA_DIST_RELATIVE_PATH);
if (
!existsSync(path.join(packageRoot, ".git")) ||
!existsSync(path.join(packageRoot, "src")) ||
!existsSync(sourceModulePath)
) {
return null;
}
return pathToFileURL(sourceModulePath).href;
}
async function dynamicImportPrivateQaCliModule(
specifier: string,
): Promise<Record<string, unknown>> {
return (await import(specifier)) as Record<string, unknown>;
}
export function loadPrivateQaCliModule(params?: {
env?: NodeJS.ProcessEnv;
cwd?: string;
argv1?: string;
moduleUrl?: string;
resolvePackageRootSync?: typeof resolveOpenClawPackageRootSync;
existsSync?: typeof fs.existsSync;
importModule?: (specifier: string) => Promise<Record<string, unknown>>;
}): Promise<Record<string, unknown>> {
const specifier = resolvePrivateQaSourceModuleSpecifier(params);
if (!specifier) {
throw new Error("Private QA CLI is only available from an OpenClaw source checkout.");
}
return (params?.importModule ?? dynamicImportPrivateQaCliModule)(specifier);
}

View File

@@ -25,6 +25,9 @@ const { registerQaLabCli } = vi.hoisted(() => ({
qa.command("run").action(() => undefined);
}),
}));
const { loadPrivateQaCliModule } = vi.hoisted(() => ({
loadPrivateQaCliModule: vi.fn(async () => ({ registerQaLabCli })),
}));
const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
const action = vi.fn();
@@ -37,7 +40,13 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
vi.mock("../capability-cli.js", () => ({ registerCapabilityCli }));
vi.mock("../../plugin-sdk/qa-lab.js", () => ({ registerQaLabCli }));
vi.mock("./private-qa-cli.js", async () => {
const actual = await vi.importActual<typeof import("./private-qa-cli.js")>("./private-qa-cli.js");
return {
...actual,
loadPrivateQaCliModule,
};
});
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
@@ -66,6 +75,7 @@ describe("registerSubCliCommands", () => {
registerNodesCli.mockClear();
nodesAction.mockClear();
registerQaLabCli.mockClear();
loadPrivateQaCliModule.mockClear();
registerCapabilityCli.mockClear();
inferAction.mockClear();
});

View File

@@ -19,6 +19,8 @@ const GENERATED_A2UI_BUNDLE = "src/canvas-host/a2ui/a2ui.bundle.js";
const GENERATED_A2UI_BUNDLE_HASH = "src/canvas-host/a2ui/.bundle.hash";
const DIST_ENTRY = "dist/entry.js";
const BUILD_STAMP = "dist/.buildstamp";
const QA_LAB_PLUGIN_SDK_ENTRY = "dist/plugin-sdk/qa-lab.js";
const QA_LAB_BUNDLED_CLI_ENTRY = "dist/extensions/qa-lab/cli.js";
const EXTENSION_SRC = bundledPluginFile("demo", "src/index.ts");
const EXTENSION_MANIFEST = bundledPluginFile("demo", "openclaw.plugin.json");
const EXTENSION_PACKAGE = bundledPluginFile("demo", "package.json");
@@ -190,6 +192,29 @@ async function runStatusCommand(params: {
});
}
async function runQaCommand(params: {
tmp: string;
spawn: (cmd: string, args: string[]) => ReturnType<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 +343,80 @@ describe("run-node script", () => {
});
});
it("skips rebuilding for private QA commands when the QA CLI facade is present", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
files: {
[ROOT_SRC]: "export const value = 1;\n",
[QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n",
[QA_LAB_BUNDLED_CLI_ENTRY]: "export const registerQaLabCli = () => {};\n",
},
oldPaths: [
ROOT_SRC,
ROOT_TSCONFIG,
ROOT_PACKAGE,
QA_LAB_PLUGIN_SDK_ENTRY,
QA_LAB_BUNDLED_CLI_ENTRY,
],
buildPaths: [DIST_ENTRY, BUILD_STAMP],
});
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
gitHead: "abc123\n",
gitStatus: "",
});
const exitCode = await runQaCommand({ tmp, spawn, spawnSync });
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
[
process.execPath,
"openclaw.mjs",
"qa",
"suite",
"--transport",
"qa-channel",
"--provider-mode",
"mock-openai",
],
]);
});
});
it("rebuilds private QA commands when the QA bundled CLI surface is missing", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {
files: {
[ROOT_SRC]: "export const value = 1;\n",
[QA_LAB_PLUGIN_SDK_ENTRY]: "export const qaLab = true;\n",
},
oldPaths: [ROOT_SRC, ROOT_TSCONFIG, ROOT_PACKAGE, QA_LAB_PLUGIN_SDK_ENTRY],
buildPaths: [DIST_ENTRY, BUILD_STAMP],
});
const { spawnCalls, spawn, spawnSync } = createSpawnRecorder({
gitHead: "abc123\n",
gitStatus: "",
});
const exitCode = await runQaCommand({ tmp, spawn, spawnSync });
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[
process.execPath,
"openclaw.mjs",
"qa",
"suite",
"--transport",
"qa-channel",
"--provider-mode",
"mock-openai",
],
]);
});
});
it("skips runtime postbuild restaging in watch mode when dist is already current", async () => {
await withTempDir({ prefix: "openclaw-run-node-" }, async (tmp) => {
await setupTrackedProject(tmp, {

View File

@@ -144,7 +144,10 @@ function getFacadeBoundaryResolvedConfig() {
return resolved;
}
function getFacadeManifestRegistry(params: { cacheKey: string }): readonly PluginManifestRecord[] {
function getFacadeManifestRegistry(params: {
cacheKey: string;
env?: NodeJS.ProcessEnv;
}): readonly PluginManifestRecord[] {
const cached = cachedManifestRegistryByKey.get(params.cacheKey);
if (cached) {
return cached;
@@ -152,6 +155,7 @@ function getFacadeManifestRegistry(params: { cacheKey: string }): readonly Plugi
const loaded = loadPluginManifestRegistry({
config: getFacadeBoundaryResolvedConfig().config,
cache: true,
...(params.env ? { env: params.env } : {}),
}).plugins;
cachedManifestRegistryByKey.set(params.cacheKey, loaded);
return loaded;
@@ -161,8 +165,12 @@ export function resolveRegistryPluginModuleLocation(params: {
dirName: string;
artifactBasename: string;
resolutionKey: string;
env?: NodeJS.ProcessEnv;
}): FacadeModuleLocation | null {
const registry = getFacadeManifestRegistry({ cacheKey: params.resolutionKey });
const registry = getFacadeManifestRegistry({
cacheKey: params.resolutionKey,
...(params.env ? { env: params.env } : {}),
});
type RegistryRecord = (typeof registry)[number];
const tiers: Array<(plugin: RegistryRecord) => boolean> = [
(plugin) => plugin.id === params.dirName,
@@ -229,6 +237,7 @@ function resolveBundledMetadataManifestRecord(params: {
artifactBasename: string;
location: FacadeModuleLocation | null;
sourceExtensionsRoot: string;
env?: NodeJS.ProcessEnv;
}): FacadePluginManifestLike | null {
if (!params.location) {
return null;
@@ -247,7 +256,7 @@ function resolveBundledMetadataManifestRecord(params: {
resolvedDirName,
});
}
const bundledPluginsDir = resolveBundledPluginsDir();
const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env);
if (!bundledPluginsDir) {
return null;
}
@@ -275,6 +284,7 @@ function resolveBundledPluginManifestRecord(params: {
location: FacadeModuleLocation | null;
sourceExtensionsRoot: string;
resolutionKey: string;
env?: NodeJS.ProcessEnv;
}): FacadePluginManifestLike | null {
if (cachedFacadeManifestRecordsByKey.has(params.resolutionKey)) {
return cachedFacadeManifestRecordsByKey.get(params.resolutionKey) ?? null;
@@ -286,7 +296,10 @@ function resolveBundledPluginManifestRecord(params: {
return metadataRecord;
}
const registry = getFacadeManifestRegistry({ cacheKey: params.resolutionKey });
const registry = getFacadeManifestRegistry({
cacheKey: params.resolutionKey,
...(params.env ? { env: params.env } : {}),
});
const resolved =
(params.location
? registry.find((plugin) => {
@@ -312,6 +325,7 @@ export function resolveTrackedFacadePluginId(params: {
location: FacadeModuleLocation | null;
sourceExtensionsRoot: string;
resolutionKey: string;
env?: NodeJS.ProcessEnv;
}): string {
return resolveBundledPluginManifestRecord(params)?.id ?? params.dirName;
}
@@ -322,6 +336,7 @@ export function resolveBundledPluginPublicSurfaceAccess(params: {
location: FacadeModuleLocation | null;
sourceExtensionsRoot: string;
resolutionKey: string;
env?: NodeJS.ProcessEnv;
}): { allowed: boolean; pluginId?: string; reason?: string } {
const cached = cachedFacadePublicSurfaceAccessByKey.get(params.resolutionKey);
if (cached) {
@@ -410,6 +425,7 @@ export function resolveActivatedBundledPluginPublicSurfaceAccessOrThrow(params:
location: FacadeModuleLocation | null;
sourceExtensionsRoot: string;
resolutionKey: string;
env?: NodeJS.ProcessEnv;
}) {
const access = resolveBundledPluginPublicSurfaceAccess(params);
if (!access.allowed) {

View File

@@ -52,16 +52,21 @@ function getOpenClawPackageRoot() {
return cachedOpenClawPackageRoot;
}
function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string }): string {
const bundledPluginsDir = resolveBundledPluginsDir();
function createFacadeResolutionKey(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): string {
const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env);
return `${params.dirName}::${params.artifactBasename}::${bundledPluginsDir ? path.resolve(bundledPluginsDir) : "<default>"}`;
}
function resolveFacadeModuleLocationUncached(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
const bundledPluginsDir = resolveBundledPluginsDir();
const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env);
const preferSource = !CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`);
if (preferSource) {
const modulePath =
@@ -71,6 +76,7 @@ function resolveFacadeModuleLocationUncached(params: {
}) ??
resolveBundledPluginPublicSurfacePath({
rootDir: getOpenClawPackageRoot(),
env: params.env,
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
dirName: params.dirName,
artifactBasename: params.artifactBasename,
@@ -88,6 +94,7 @@ function resolveFacadeModuleLocationUncached(params: {
}
const modulePath = resolveBundledPluginPublicSurfacePath({
rootDir: getOpenClawPackageRoot(),
env: params.env,
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
dirName: params.dirName,
artifactBasename: params.artifactBasename,
@@ -107,6 +114,7 @@ function resolveFacadeModuleLocationUncached(params: {
function resolveFacadeModuleLocation(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
const key = createFacadeResolutionKey(params);
if (cachedFacadeModuleLocationsByKey.has(key)) {
@@ -253,6 +261,7 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(param
dirName: string;
artifactBasename: string;
trackedPluginId?: string | (() => string);
env?: NodeJS.ProcessEnv;
}): T {
const location = resolveFacadeModuleLocation(params);
if (!location) {

View File

@@ -42,8 +42,12 @@ const cachedFacadeModuleLocationsByKey = new Map<
} | null
>();
function createFacadeResolutionKey(params: { dirName: string; artifactBasename: string }): string {
const bundledPluginsDir = resolveBundledPluginsDir();
function createFacadeResolutionKey(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): string {
const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env);
return `${params.dirName}::${params.artifactBasename}::${bundledPluginsDir ? path.resolve(bundledPluginsDir) : "<default>"}`;
}
@@ -81,6 +85,7 @@ function resolveRegistryPluginModuleLocationFromRegistry(params: {
function resolveRegistryPluginModuleLocation(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
return loadFacadeActivationCheckRuntime().resolveRegistryPluginModuleLocation({
...params,
@@ -91,8 +96,9 @@ function resolveRegistryPluginModuleLocation(params: {
function resolveFacadeModuleLocationUncached(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
const bundledPluginsDir = resolveBundledPluginsDir();
const bundledPluginsDir = resolveBundledPluginsDir(params.env ?? process.env);
const preferSource = !CURRENT_MODULE_PATH.includes(`${path.sep}dist${path.sep}`);
if (preferSource) {
const modulePath =
@@ -102,6 +108,7 @@ function resolveFacadeModuleLocationUncached(params: {
}) ??
resolveBundledPluginPublicSurfacePath({
rootDir: OPENCLAW_PACKAGE_ROOT,
env: params.env,
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
dirName: params.dirName,
artifactBasename: params.artifactBasename,
@@ -119,6 +126,7 @@ function resolveFacadeModuleLocationUncached(params: {
}
const modulePath = resolveBundledPluginPublicSurfacePath({
rootDir: OPENCLAW_PACKAGE_ROOT,
env: params.env,
...(bundledPluginsDir ? { bundledPluginsDir } : {}),
dirName: params.dirName,
artifactBasename: params.artifactBasename,
@@ -138,6 +146,7 @@ function resolveFacadeModuleLocationUncached(params: {
function resolveFacadeModuleLocation(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): { modulePath: string; boundaryRoot: string } | null {
const key = createFacadeResolutionKey(params);
if (cachedFacadeModuleLocationsByKey.has(key)) {
@@ -151,6 +160,7 @@ function resolveFacadeModuleLocation(params: {
type BundledPluginPublicSurfaceParams = {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
};
type FacadeActivationCheckRuntimeModule = typeof import("./facade-activation-check.runtime.js");
@@ -252,6 +262,7 @@ export function loadBundledPluginPublicSurfaceModuleSync<T extends object>(
export function canLoadActivatedBundledPluginPublicSurface(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): boolean {
return loadFacadeActivationCheckRuntime().resolveBundledPluginPublicSurfaceAccess(
buildFacadeActivationCheckParams(params),
@@ -261,6 +272,7 @@ export function canLoadActivatedBundledPluginPublicSurface(params: {
export function loadActivatedBundledPluginPublicSurfaceModuleSync<T extends object>(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): T {
loadFacadeActivationCheckRuntime().resolveActivatedBundledPluginPublicSurfaceAccessOrThrow(
buildFacadeActivationCheckParams(params),
@@ -271,6 +283,7 @@ export function loadActivatedBundledPluginPublicSurfaceModuleSync<T extends obje
export function tryLoadActivatedBundledPluginPublicSurfaceModuleSync<T extends object>(params: {
dirName: string;
artifactBasename: string;
env?: NodeJS.ProcessEnv;
}): T | null {
const access = loadFacadeActivationCheckRuntime().resolveBundledPluginPublicSurfaceAccess(
buildFacadeActivationCheckParams(params),

View File

@@ -0,0 +1,31 @@
import fs from "node:fs";
import path from "node:path";
import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js";
export function resolvePrivateQaBundledPluginsEnv(
env: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv | undefined {
if (env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") {
return undefined;
}
const packageRoot = resolveOpenClawPackageRootSync({
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
if (!packageRoot) {
return undefined;
}
const sourceExtensionsDir = path.join(packageRoot, "extensions");
if (
!fs.existsSync(path.join(packageRoot, ".git")) ||
!fs.existsSync(path.join(packageRoot, "src")) ||
!fs.existsSync(sourceExtensionsDir)
) {
return undefined;
}
return {
...env,
OPENCLAW_BUNDLED_PLUGINS_DIR: sourceExtensionsDir,
};
}

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 () => {
@@ -48,6 +72,34 @@ describe("plugin-sdk qa-runner-runtime", () => {
});
});
it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runtime-root-"));
tempDirs.push(sourceRoot);
fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const runtimeSurface = {
defaultQaRuntimeModelForMode: vi.fn(),
startQaLiveLaneGateway: vi.fn(),
};
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue(runtimeSurface);
const module = await import("./qa-runner-runtime.js");
expect(module.loadQaRuntimeModule()).toBe(runtimeSurface);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "qa-lab",
artifactBasename: "runtime-api.js",
env: expect.objectContaining({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"),
}),
});
});
it("reports the qa runtime as unavailable when the qa-lab surface is missing", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("Unable to resolve bundled plugin public surface qa-lab/runtime-api.js");
@@ -125,6 +177,61 @@ describe("plugin-sdk qa-runner-runtime", () => {
]);
});
it("prefers the source bundled tree for private qa discovery in repo checkouts", async () => {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runner-root-"));
tempDirs.push(sourceRoot);
fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const register = vi.fn((qa: Command) => qa);
loadPluginManifestRegistry.mockReturnValue({
plugins: [
{
id: "qa-matrix",
origin: "bundled",
qaRunners: [{ commandName: "matrix" }],
rootDir: path.join(sourceRoot, "extensions", "qa-matrix"),
},
],
diagnostics: [],
});
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({
qaRunnerCliRegistrations: [{ commandName: "matrix", register }],
});
const module = await import("./qa-runner-runtime.js");
expect(module.listQaRunnerCliContributions()).toEqual([
{
pluginId: "qa-matrix",
commandName: "matrix",
status: "available",
registration: {
commandName: "matrix",
register,
},
},
]);
expect(loadPluginManifestRegistry).toHaveBeenCalledWith({
cache: true,
env: expect.objectContaining({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"),
}),
});
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "qa-matrix",
artifactBasename: "runtime-api.js",
env: expect.objectContaining({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"),
}),
});
});
it("fails fast when two plugins declare the same qa runner command", async () => {
loadPluginManifestRegistry.mockReturnValue({
plugins: [

View File

@@ -5,6 +5,7 @@ import {
loadBundledPluginPublicSurfaceModuleSync,
tryLoadActivatedBundledPluginPublicSurfaceModuleSync,
} from "./facade-runtime.js";
import { resolvePrivateQaBundledPluginsEnv } from "./private-qa-bundled-env.js";
export type QaRunnerCliRegistration = {
commandName: string;
@@ -53,9 +54,11 @@ function isMissingQaRuntimeError(error: unknown) {
}
export function loadQaRuntimeModule(): QaRuntimeSurface {
const env = resolvePrivateQaBundledPluginsEnv();
return loadBundledPluginPublicSurfaceModuleSync<QaRuntimeSurface>({
dirName: ["qa", "lab"].join("-"),
artifactBasename: ["runtime-api", "js"].join("."),
...(env ? { env } : {}),
});
}
@@ -71,12 +74,14 @@ export function isQaRuntimeAvailable(): boolean {
}
}
function listDeclaredQaRunnerPlugins(): Array<
function listDeclaredQaRunnerPlugins(
env: NodeJS.ProcessEnv | undefined = resolvePrivateQaBundledPluginsEnv(),
): Array<
PluginManifestRecord & {
qaRunners: NonNullable<PluginManifestRecord["qaRunners"]>;
}
> {
return loadPluginManifestRegistry({ cache: true })
return loadPluginManifestRegistry({ cache: true, ...(env ? { env } : {}) })
.plugins.filter(
(
plugin,
@@ -113,24 +118,30 @@ function indexRuntimeRegistrations(
return registrationByCommandName;
}
function loadQaRunnerRuntimeSurface(plugin: PluginManifestRecord): QaRunnerRuntimeSurface | null {
function loadQaRunnerRuntimeSurface(
plugin: PluginManifestRecord,
env?: NodeJS.ProcessEnv,
): QaRunnerRuntimeSurface | null {
if (plugin.origin === "bundled") {
return loadBundledPluginPublicSurfaceModuleSync<QaRunnerRuntimeSurface>({
dirName: plugin.id,
artifactBasename: "runtime-api.js",
...(env ? { env } : {}),
});
}
return tryLoadActivatedBundledPluginPublicSurfaceModuleSync<QaRunnerRuntimeSurface>({
dirName: plugin.id,
artifactBasename: "runtime-api.js",
...(env ? { env } : {}),
});
}
export function listQaRunnerCliContributions(): readonly QaRunnerCliContribution[] {
const env = resolvePrivateQaBundledPluginsEnv();
const contributions = new Map<string, QaRunnerCliContribution>();
for (const plugin of listDeclaredQaRunnerPlugins()) {
const runtimeSurface = loadQaRunnerRuntimeSurface(plugin);
for (const plugin of listDeclaredQaRunnerPlugins(env)) {
const runtimeSurface = loadQaRunnerRuntimeSurface(plugin, env);
const runtimeRegistrationByCommandName = runtimeSurface
? indexRuntimeRegistrations(plugin.id, runtimeSurface)
: null;

View File

@@ -1,14 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn());
const resolveOpenClawPackageRootSync = vi.hoisted(() => vi.fn());
vi.mock("./facade-runtime.js", () => ({
loadBundledPluginPublicSurfaceModuleSync,
}));
vi.mock("../infra/openclaw-root.js", () => ({
resolveOpenClawPackageRootSync,
}));
describe("plugin-sdk qa-runtime", () => {
const tempDirs: string[] = [];
const originalPrivateQaCli = process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
beforeEach(() => {
loadBundledPluginPublicSurfaceModuleSync.mockReset();
resolveOpenClawPackageRootSync.mockReset().mockReturnValue(null);
delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
});
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
if (originalPrivateQaCli === undefined) {
delete process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI;
} else {
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = originalPrivateQaCli;
}
});
it("stays cold until the runtime seam is used", async () => {
@@ -35,6 +59,34 @@ describe("plugin-sdk qa-runtime", () => {
});
});
it("uses the source bundled tree for qa-lab runtime loading in private qa mode", async () => {
const sourceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qa-runtime-root-"));
tempDirs.push(sourceRoot);
fs.mkdirSync(path.join(sourceRoot, "src"), { recursive: true });
fs.mkdirSync(path.join(sourceRoot, "extensions"), { recursive: true });
fs.writeFileSync(path.join(sourceRoot, ".git"), "gitdir: /tmp/mock\n", "utf8");
process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI = "1";
resolveOpenClawPackageRootSync.mockReturnValue(sourceRoot);
const runtimeSurface = {
defaultQaRuntimeModelForMode: vi.fn(),
startQaLiveLaneGateway: vi.fn(),
};
loadBundledPluginPublicSurfaceModuleSync.mockReturnValue(runtimeSurface);
const module = await import("./qa-runtime.js");
expect(module.loadQaRuntimeModule()).toBe(runtimeSurface);
expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({
dirName: "qa-lab",
artifactBasename: "runtime-api.js",
env: expect.objectContaining({
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1",
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(sourceRoot, "extensions"),
}),
});
});
it("reports the runtime as unavailable when the qa-lab surface is missing", async () => {
loadBundledPluginPublicSurfaceModuleSync.mockImplementation(() => {
throw new Error("Unable to resolve bundled plugin public surface qa-lab/runtime-api.js");

View File

@@ -1,4 +1,5 @@
import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js";
import { resolvePrivateQaBundledPluginsEnv } from "./private-qa-bundled-env.js";
type QaRuntimeSurface = {
defaultQaRuntimeModelForMode: (
@@ -20,9 +21,11 @@ function isMissingQaRuntimeError(error: unknown) {
}
export function loadQaRuntimeModule(): QaRuntimeSurface {
const env = resolvePrivateQaBundledPluginsEnv();
return loadBundledPluginPublicSurfaceModuleSync<QaRuntimeSurface>({
dirName: "qa-lab",
artifactBasename: "runtime-api.js",
...(env ? { env } : {}),
});
}

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,46 @@ function listPluginSdkExportedSubpaths() {
subpaths = [];
}
pluginSdkSubpathsCache.set(packageRoot, subpaths);
pluginSdkSubpathsCache.set(cacheKey, subpaths);
return subpaths;
}
function listPrivateLocalOnlyPluginSdkSubpaths() {
if (process.env.OPENCLAW_ENABLE_PRIVATE_QA_CLI !== "1") {
return [];
}
try {
const raw = fs.readFileSync(
path.join(getPackageRoot(), "scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"),
"utf8",
);
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) {
return [];
}
return parsed.filter(
(subpath) => typeof subpath === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(subpath),
);
} catch {
return [];
}
}
function listPluginSdkRootAliasSubpaths() {
const exportedSubpaths = listPluginSdkExportedSubpaths();
return [...new Set([...exportedSubpaths, ...listPrivateLocalOnlyPluginSdkSubpaths()])].toSorted(
(left, right) => left.localeCompare(right),
);
}
function buildPluginSdkAliasMap(useDist) {
const packageRoot = getPackageRoot();
const pluginSdkDir = path.join(packageRoot, useDist ? "dist" : "src", "plugin-sdk");
const normalizeTarget = (target) =>
process.platform === "win32" ? target.replace(/\\/g, "/") : target;
const aliasMap = Object.fromEntries(
pluginSdkPackageNames.map((packageName) => [packageName, normalizeTarget(__filename)]),
);
const aliasMap = {};
for (const subpath of listPluginSdkExportedSubpaths()) {
for (const subpath of listPluginSdkRootAliasSubpaths()) {
if (useDist) {
const candidate = path.join(pluginSdkDir, `${subpath}.js`);
if (fs.existsSync(candidate)) {
@@ -158,6 +185,12 @@ function buildPluginSdkAliasMap(useDist) {
}
}
// Keep the bare root alias last so subpath aliases win under resolvers that
// perform prefix matching instead of exact-key lookup.
for (const packageName of pluginSdkPackageNames) {
aliasMap[packageName] = normalizeTarget(__filename);
}
return aliasMap;
}

View File

@@ -29,6 +29,7 @@ function loadRootAliasWithStubs(options?: {
packageExports?: Record<string, unknown>;
platform?: string;
existingPaths?: string[];
privateLocalOnlySubpaths?: unknown;
}) {
let createJitiCalls = 0;
let jitiLoadCalls = 0;
@@ -61,13 +62,21 @@ function loadRootAliasWithStubs(options?: {
}
if (id === "node:fs") {
return {
readFileSync: () =>
JSON.stringify({
readFileSync: (targetPath: string) => {
if (
targetPath.endsWith(
path.join("scripts", "lib", "plugin-sdk-private-local-only-subpaths.json"),
)
) {
return JSON.stringify(options?.privateLocalOnlySubpaths ?? []);
}
return JSON.stringify({
exports: {
"./plugin-sdk/group-access": { default: "./dist/plugin-sdk/group-access.js" },
...options?.packageExports,
},
}),
});
},
existsSync: (targetPath: string) => {
if (targetPath.endsWith(path.join("dist", "infra", "diagnostic-events.js"))) {
return options?.distExists ?? false;
@@ -298,17 +307,40 @@ describe("plugin-sdk root alias", () => {
(lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record<string, string>,
);
expect(aliasKeys).toEqual([
"openclaw/plugin-sdk",
"@openclaw/plugin-sdk",
"openclaw/plugin-sdk/alpha",
"@openclaw/plugin-sdk/alpha",
"openclaw/plugin-sdk/group-access",
"@openclaw/plugin-sdk/group-access",
"openclaw/plugin-sdk/zeta",
"@openclaw/plugin-sdk/zeta",
"openclaw/plugin-sdk",
"@openclaw/plugin-sdk",
]);
});
it("ignores unsafe private local-only plugin-sdk subpaths in the CJS root alias", () => {
const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath)));
const lazyModule = loadRootAliasWithStubs({
env: { OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1" },
privateLocalOnlySubpaths: ["qa-lab", "../escape", "nested/path"],
existingPaths: [path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts")],
monolithicExports: {
slowHelper: (): string => "loaded",
},
});
expect((lazyModule.moduleExports.slowHelper as () => string)()).toBe("loaded");
const aliasMap = (lazyModule.createJitiOptions.at(-1)?.alias ?? {}) as Record<string, string>;
expect(aliasMap["openclaw/plugin-sdk/qa-lab"]).toBe(
path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"),
);
expect(aliasMap["@openclaw/plugin-sdk/qa-lab"]).toBe(
path.join(packageRoot, "src", "plugin-sdk", "qa-lab.ts"),
);
expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/../escape");
expect(aliasMap).not.toHaveProperty("openclaw/plugin-sdk/nested/path");
});
it("builds source plugin-sdk subpath aliases through the wider source extension family", () => {
const packageRoot = path.dirname(path.dirname(path.dirname(rootAliasPath)));
const lazyModule = loadRootAliasWithStubs({

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;