mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 17:31:06 +00:00
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:
@@ -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 };
|
||||
|
||||
314
extensions/qa-lab/src/bundled-plugin-staging.ts
Normal file
314
extensions/qa-lab/src/bundled-plugin-staging.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
41
extensions/qa-lab/src/node-exec.test.ts
Normal file
41
extensions/qa-lab/src/node-exec.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
64
extensions/qa-lab/src/node-exec.ts
Normal file
64
extensions/qa-lab/src/node-exec.ts
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
76
extensions/qa-lab/src/scenario-flow-runner.test.ts
Normal file
76
extensions/qa-lab/src/scenario-flow-runner.test.ts
Normal 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -68,6 +68,7 @@ function getPathWithParent(
|
||||
function createEvalContext(api: QaFlowApi, vars: QaFlowVars) {
|
||||
return {
|
||||
...api,
|
||||
qaImport: (specifier: string) => import(specifier),
|
||||
vars,
|
||||
...vars,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
4
scripts/lib/plugin-sdk-private-local-only-subpaths.json
Normal file
4
scripts/lib/plugin-sdk-private-local-only-subpaths.json
Normal file
@@ -0,0 +1,4 @@
|
||||
[
|
||||
"qa-lab",
|
||||
"qa-runtime"
|
||||
]
|
||||
@@ -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";
|
||||
|
||||
@@ -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>>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user