mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:40:44 +00:00
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:
committed by
GitHub
parent
489404d75e
commit
d5933af80b
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
417
extensions/qa-lab/src/bundled-plugin-staging.ts
Normal file
417
extensions/qa-lab/src/bundled-plugin-staging.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
11
extensions/qa-lab/src/qa-transport-registry.test.ts
Normal file
11
extensions/qa-lab/src/qa-transport-registry.test.ts
Normal 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__",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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"
|
||||
]
|
||||
@@ -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";
|
||||
|
||||
79
src/cli/program/private-qa-cli.test.ts
Normal file
79
src/cli/program/private-qa-cli.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
31
src/plugin-sdk/private-qa-bundled-env.ts
Normal file
31
src/plugin-sdk/private-qa-bundled-env.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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: [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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