From 9b743d87686f8451a5e528679eca1a35231a0eaa Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 15 Apr 2026 19:30:46 -0400 Subject: [PATCH] fix: harden private qa runtime loading --- CHANGELOG.md | 1 + .../qa-lab/src/bundled-plugin-staging.ts | 93 ++++++++++++++++--- extensions/qa-lab/src/gateway-child.test.ts | 84 +++++++++++++++-- src/cli/program/private-qa-cli.test.ts | 13 +++ src/cli/program/private-qa-cli.ts | 2 +- src/cli/program/register.subclis.test.ts | 2 +- 6 files changed, 173 insertions(+), 22 deletions(-) create mode 100644 src/cli/program/private-qa-cli.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c5cfe7c4718..1dc12141dd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/qa-lab/src/bundled-plugin-staging.ts b/extensions/qa-lab/src/bundled-plugin-staging.ts index 3b5018dec64..cb1d62e1057 100644 --- a/extensions/qa-lab/src/bundled-plugin-staging.ts +++ b/extensions/qa-lab/src/bundled-plugin-staging.ts @@ -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(); + 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; diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 16fd3ce680d..ac4fed0c53d 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -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 () => { diff --git a/src/cli/program/private-qa-cli.test.ts b/src/cli/program/private-qa-cli.test.ts new file mode 100644 index 00000000000..7462bb188ea --- /dev/null +++ b/src/cli/program/private-qa-cli.test.ts @@ -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), + }); + }); +}); diff --git a/src/cli/program/private-qa-cli.ts b/src/cli/program/private-qa-cli.ts index 5bedec7a8be..ae093d2450e 100644 --- a/src/cli/program/private-qa-cli.ts +++ b/src/cli/program/private-qa-cli.ts @@ -3,6 +3,6 @@ export function isPrivateQaCliEnabled(env: NodeJS.ProcessEnv = process.env): boo } export function loadPrivateQaCliModule(): Promise> { - const specifier = "openclaw/plugin-sdk/qa-lab"; + const specifier = "../../plugin-sdk/qa-lab.js"; return import(specifier) as Promise>; } diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 94428cb7646..594714aff44 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -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;