fix: harden private qa runtime loading

This commit is contained in:
Gustavo Madeira Santana
2026-04-15 19:30:46 -04:00
parent 4d8b1da1fb
commit 9b743d8768
6 changed files with 173 additions and 22 deletions

View File

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

View File

@@ -165,6 +165,22 @@ function resolveQaStagedBundledTreeName(repoRoot: string) {
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;
@@ -196,6 +212,57 @@ async function seedQaStagedNodeModules(params: { repoRoot: string; stagedRoot: s
}
}
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: entry.isDirectory(),
});
}
}
}
export async function resolveQaRuntimeHostVersion(params: {
repoRoot: string;
allowedPluginIds: readonly string[];
@@ -254,7 +321,10 @@ export async function createQaBundledPluginsDir(params: {
);
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 fs.copyFile(
path.join(params.repoRoot, "package.json"),
path.join(stagedRoot, "package.json"),
);
await seedQaStagedNodeModules({
repoRoot: params.repoRoot,
stagedRoot,
@@ -266,21 +336,16 @@ export async function createQaBundledPluginsDir(params: {
path.join(stagedOpenClawPackageDir, "package.json"),
);
const stagedTreeName = resolveQaStagedBundledTreeName(params.repoRoot);
const sourceTreeRoot = path.join(params.repoRoot, stagedTreeName);
const stagedTreeRoot = path.join(stagedRoot, stagedTreeName);
await fs.mkdir(stagedTreeRoot, { recursive: true });
if (existsSync(sourceTreeRoot)) {
for (const entry of await fs.readdir(sourceTreeRoot, { withFileTypes: true })) {
if (entry.name === "extensions") {
continue;
}
await symlinkQaStagedDirEntry({
sourcePath: path.join(sourceTreeRoot, entry.name),
targetPath: path.join(stagedTreeRoot, entry.name),
directory: entry.isDirectory(),
});
}
}
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;

View File

@@ -654,9 +654,7 @@ describe("qa bundled plugin dir", () => {
repoRoot,
pluginId: "qa-channel",
}),
).toBe(
path.join(repoRoot, "dist", "extensions", "qa-channel"),
);
).toBe(path.join(repoRoot, "dist", "extensions", "qa-channel"));
});
it("falls back to the source bundled plugin when no built copy exists", async () => {
@@ -672,9 +670,7 @@ describe("qa bundled plugin dir", () => {
repoRoot,
pluginId: "qa-channel",
}),
).toBe(
path.join(repoRoot, "extensions", "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 () => {
@@ -784,6 +780,82 @@ 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("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 () => {

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "vitest";
import { loadPrivateQaCliModule } from "./private-qa-cli.js";
describe("private-qa-cli", () => {
it("loads the private QA CLI facade through the local plugin-sdk entrypoint", async () => {
const module = await loadPrivateQaCliModule();
expect(module).toMatchObject({
isQaLabCliAvailable: expect.any(Function),
registerQaLabCli: expect.any(Function),
});
});
});

View File

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

View File

@@ -37,7 +37,7 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
vi.mock("../capability-cli.js", () => ({ registerCapabilityCli }));
vi.mock("openclaw/plugin-sdk/qa-lab", () => ({ registerQaLabCli }));
vi.mock("../../plugin-sdk/qa-lab.js", () => ({ registerQaLabCli }));
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;