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, 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> = []; afterEach(async () => { fetchWithSsrFGuardMock.mockReset(); resolveQaNodeExecPathMock.mockReset(); qaTempPathState.preferredTmpDir = process.env.TMPDIR || "/tmp"; while (cleanups.length > 0) { await cleanups.pop()?.(); } }); function createParams(baseEnv?: NodeJS.ProcessEnv) { return { configPath: "/tmp/openclaw-qa/openclaw.json", gatewayToken: "qa-token", homeDir: "/tmp/openclaw-qa/home", stateDir: "/tmp/openclaw-qa/state", xdgConfigHome: "/tmp/openclaw-qa/xdg-config", xdgDataHome: "/tmp/openclaw-qa/xdg-data", xdgCacheHome: "/tmp/openclaw-qa/xdg-cache", bundledPluginsDir: "/tmp/openclaw-qa/bundled-plugins", compatibilityHostVersion: "2026.4.8", baseEnv, }; } 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(), providerMode: "mock-openai", }); expect(env.OPENCLAW_TEST_FAST).toBe("1"); expect(env.OPENCLAW_QA_ALLOW_LOCAL_IMAGE_PROVIDER).toBe("1"); expect(env.OPENCLAW_ALLOW_SLOW_REPLY_TESTS).toBe("1"); expect(env.OPENCLAW_BUNDLED_PLUGINS_DIR).toBe("/tmp/openclaw-qa/bundled-plugins"); expect(env.OPENCLAW_COMPATIBILITY_HOST_VERSION).toBe("2026.4.8"); }); it("maps live frontier key aliases into provider env vars", () => { const env = buildQaRuntimeEnv({ ...createParams({ OPENCLAW_LIVE_OPENAI_KEY: "openai-live", OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", }), providerMode: "live-frontier", }); expect(env.OPENAI_API_KEY).toBe("openai-live"); expect(env.ANTHROPIC_API_KEY).toBe("anthropic-live"); expect(env.GEMINI_API_KEY).toBe("gemini-live"); }); it("defaults gateway-child provider mode to mock-openai when omitted", () => { expect(__testing.resolveQaGatewayChildProviderMode(undefined)).toBe("mock-openai"); expect(__testing.resolveQaGatewayChildProviderMode("live-frontier")).toBe("live-frontier"); }); it("keeps explicit provider env vars over live aliases", () => { const env = buildQaRuntimeEnv({ ...createParams({ OPENAI_API_KEY: "openai-explicit", OPENCLAW_LIVE_OPENAI_KEY: "openai-live", }), providerMode: "live-frontier", }); expect(env.OPENAI_API_KEY).toBe("openai-explicit"); }); it("preserves Codex CLI auth home for live frontier runs while sandboxing OpenClaw home", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); const codexHome = path.join(hostHome, ".codex"); await mkdir(codexHome); const env = buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, }), providerMode: "live-frontier", }); expect(env.HOME).toBe("/tmp/openclaw-qa/home"); expect(env.OPENCLAW_HOME).toBe("/tmp/openclaw-qa/home"); expect(env.CODEX_HOME).toBe(codexHome); }); it("forwards host HOME for live Claude CLI runs while keeping OpenClaw home sandboxed", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); const env = buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, }), providerMode: "live-frontier", forwardHostHomeForClaudeCli: true, }); expect(env.HOME).toBe(hostHome); expect(env.OPENCLAW_HOME).toBe("/tmp/openclaw-qa/home"); expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-qa/state"); }); it("can forward host HOME for browser-backed QA runs while keeping OpenClaw home sandboxed", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); const env = buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, }), providerMode: "mock-openai", forwardHostHome: true, }); expect(env.HOME).toBe(hostHome); expect(env.OPENCLAW_HOME).toBe("/tmp/openclaw-qa/home"); expect(env.OPENCLAW_STATE_DIR).toBe("/tmp/openclaw-qa/state"); }); it("preserves the live Anthropic key for live Claude CLI runs without writing it into config", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); const env = buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV: '["SAFE_KEEP"]', }), providerMode: "live-frontier", forwardHostHomeForClaudeCli: true, claudeCliAuthMode: "api-key", }); expect(env.ANTHROPIC_API_KEY).toBe("anthropic-live"); expect(env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV).toBe('["SAFE_KEEP","ANTHROPIC_API_KEY"]'); expect(env.OPENCLAW_LIVE_CLI_BACKEND_AUTH_MODE).toBe("api-key"); }); it("removes preserved Anthropic keys for live Claude CLI subscription runs", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); const env = buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, ANTHROPIC_API_KEY: "anthropic-live", OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV: '["SAFE_KEEP","ANTHROPIC_API_KEY"]', }), providerMode: "live-frontier", forwardHostHomeForClaudeCli: true, claudeCliAuthMode: "subscription", }); expect(env.ANTHROPIC_API_KEY).toBe("anthropic-live"); expect(env.OPENCLAW_LIVE_CLI_BACKEND_PRESERVE_ENV).toBe('["SAFE_KEEP"]'); expect(env.OPENCLAW_LIVE_CLI_BACKEND_AUTH_MODE).toBe("subscription"); }); it("does not pass QA setup-token values to the gateway child env", () => { const env = buildQaRuntimeEnv({ ...createParams({ OPENCLAW_LIVE_SETUP_TOKEN_VALUE: `sk-ant-oat01-${"a".repeat(80)}`, OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN: `sk-ant-oat01-${"b".repeat(80)}`, }), providerMode: "live-frontier", }); expect(env.OPENCLAW_LIVE_SETUP_TOKEN_VALUE).toBeUndefined(); expect(env.OPENCLAW_QA_LIVE_ANTHROPIC_SETUP_TOKEN).toBeUndefined(); }); it("requires an Anthropic key for live Claude CLI API-key mode", async () => { const hostHome = await mkdtemp(path.join(os.tmpdir(), "qa-host-home-")); cleanups.push(async () => { await rm(hostHome, { recursive: true, force: true }); }); expect(() => buildQaRuntimeEnv({ ...createParams({ HOME: hostHome, }), providerMode: "live-frontier", forwardHostHomeForClaudeCli: true, claudeCliAuthMode: "api-key", }), ).toThrow("Claude CLI API-key QA mode requires ANTHROPIC_API_KEY"); }); it("keeps explicit Codex CLI auth home for live frontier runs", () => { const env = buildQaRuntimeEnv({ ...createParams({ CODEX_HOME: "/custom/codex-home", HOME: "/host/home", }), providerMode: "live-frontier", }); expect(env.CODEX_HOME).toBe("/custom/codex-home"); }); it.each(["mock-openai", "aimock"] as const)( "scrubs direct and live provider keys in %s mode", (providerMode) => { const env = buildQaRuntimeEnv({ ...createParams({ ANTHROPIC_API_KEY: "anthropic-live", ANTHROPIC_OAUTH_TOKEN: "anthropic-oauth", GEMINI_API_KEY: "gemini-live", GEMINI_API_KEYS: "gemini-a gemini-b", GOOGLE_API_KEY: "google-live", OPENAI_API_KEY: "openai-live", OPENAI_API_KEYS: "openai-a,openai-b", CODEX_HOME: "/host/.codex", OPENCLAW_LIVE_ANTHROPIC_KEY: "anthropic-live", OPENCLAW_LIVE_ANTHROPIC_KEYS: "anthropic-a,anthropic-b", OPENCLAW_LIVE_GEMINI_KEY: "gemini-live", OPENCLAW_LIVE_OPENAI_KEY: "openai-live", }), providerMode, }); expect(env.OPENAI_API_KEY).toBeUndefined(); expect(env.OPENAI_API_KEYS).toBeUndefined(); expect(env.CODEX_HOME).toBeUndefined(); expect(env.ANTHROPIC_API_KEY).toBeUndefined(); expect(env.ANTHROPIC_OAUTH_TOKEN).toBeUndefined(); expect(env.GEMINI_API_KEY).toBeUndefined(); expect(env.GEMINI_API_KEYS).toBeUndefined(); expect(env.GOOGLE_API_KEY).toBeUndefined(); expect(env.OPENCLAW_LIVE_OPENAI_KEY).toBeUndefined(); expect(env.OPENCLAW_LIVE_ANTHROPIC_KEY).toBeUndefined(); expect(env.OPENCLAW_LIVE_ANTHROPIC_KEYS).toBeUndefined(); expect(env.OPENCLAW_LIVE_GEMINI_KEY).toBeUndefined(); }, ); it("treats restart socket closures as retryable gateway call errors", () => { expect(__testing.isRetryableGatewayCallError("gateway closed (1006 abnormal closure)")).toBe( true, ); expect(__testing.isRetryableGatewayCallError("gateway closed (1012 service restart)")).toBe( true, ); expect(__testing.isRetryableGatewayCallError("service restart in progress")).toBe(true); expect(__testing.isRetryableGatewayCallError("permission denied")).toBe(false); }); it("stages a live Anthropic setup-token profile for isolated QA workers", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-setup-token-state-")); cleanups.push(async () => { await rm(stateDir, { recursive: true, force: true }); }); const token = `sk-ant-oat01-${"c".repeat(80)}`; const cfg = await __testing.stageQaLiveAnthropicSetupToken({ cfg: {}, stateDir, env: { OPENCLAW_LIVE_SETUP_TOKEN_VALUE: token, }, }); expect(cfg.auth?.profiles?.["anthropic:qa-setup-token"]).toMatchObject({ provider: "anthropic", mode: "token", }); const storeRaw = await readFile( path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"), "utf8", ); expect(JSON.parse(storeRaw)).toMatchObject({ profiles: { "anthropic:qa-setup-token": { type: "token", provider: "anthropic", token, }, }, }); }); it("stages placeholder mock auth profiles per agent dir so mock-openai runs can resolve credentials", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-mock-auth-")); cleanups.push(async () => { await rm(stateDir, { recursive: true, force: true }); }); const cfg = await __testing.stageQaMockAuthProfiles({ cfg: {}, stateDir, }); // Config side: both providers should have a profile entry with mode // "api_key" so the runtime picks up the staging without any further // config mutation. expect(cfg.auth?.profiles?.["qa-mock-openai"]).toMatchObject({ provider: "openai", mode: "api_key", displayName: "QA mock openai credential", }); expect(cfg.auth?.profiles?.["qa-mock-anthropic"]).toMatchObject({ provider: "anthropic", mode: "api_key", displayName: "QA mock anthropic credential", }); // Store side: each agent dir should have its own auth-profiles.json // containing the placeholder credential for each staged provider. This // is what the scenario runner actually reads when it resolves auth // before calling the mock. for (const agentId of ["main", "qa"]) { const storeRaw = await readFile( path.join(stateDir, "agents", agentId, "agent", "auth-profiles.json"), "utf8", ); const parsed = JSON.parse(storeRaw) as { profiles: Record; }; expect(parsed.profiles["qa-mock-openai"]).toMatchObject({ type: "api_key", provider: "openai", key: "qa-mock-not-a-real-key", }); expect(parsed.profiles["qa-mock-anthropic"]).toMatchObject({ type: "api_key", provider: "anthropic", key: "qa-mock-not-a-real-key", }); } }); it("stages mock profiles only for the requested agents and providers when callers override the defaults", async () => { const stateDir = await mkdtemp(path.join(os.tmpdir(), "qa-mock-auth-override-")); cleanups.push(async () => { await rm(stateDir, { recursive: true, force: true }); }); const cfg = await __testing.stageQaMockAuthProfiles({ cfg: {}, stateDir, agentIds: ["qa"], providers: ["openai"], }); expect(cfg.auth?.profiles?.["qa-mock-openai"]).toMatchObject({ provider: "openai", mode: "api_key", }); // Anthropic should NOT be staged when the caller restricts providers. expect(cfg.auth?.profiles?.["qa-mock-anthropic"]).toBeUndefined(); const qaStore = JSON.parse( await readFile(path.join(stateDir, "agents", "qa", "agent", "auth-profiles.json"), "utf8"), ) as { profiles: Record }; expect(qaStore.profiles["qa-mock-openai"]).toBeDefined(); expect(qaStore.profiles["qa-mock-anthropic"]).toBeUndefined(); // main/agent should not exist because it wasn't in the agentIds list. await expect( readFile(path.join(stateDir, "agents", "main", "agent", "auth-profiles.json"), "utf8"), ).rejects.toThrow(/ENOENT/); }); it("allows loopback gateway health probes through the SSRF guard", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ response: { ok: true }, release, }); await expect( __testing.fetchLocalGatewayHealth({ baseUrl: "http://127.0.0.1:18789", healthPath: "/readyz", }), ).resolves.toBe(true); expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( expect.objectContaining({ url: "http://127.0.0.1:18789/readyz", policy: { allowPrivateNetwork: true }, auditContext: "qa-lab-gateway-child-health", }), ); expect(release).toHaveBeenCalledTimes(1); }); it("force-stops gateway children that ignore the graceful signal", async () => { const child = spawn( process.execPath, [ "-e", [ "process.on('SIGTERM', () => {});", "process.stdout.write('ready\\n');", "setInterval(() => {}, 1000);", ].join(""), ], { detached: process.platform !== "win32", stdio: ["ignore", "pipe", "ignore"], }, ); cleanups.push(async () => { if (child.exitCode === null && child.signalCode === null) { try { if (process.platform === "win32") { child.kill("SIGKILL"); } else if (child.pid) { process.kill(-child.pid, "SIGKILL"); } } catch { // The child already exited. } } }); await new Promise((resolve, reject) => { child.once("error", reject); child.stdout?.once("data", () => resolve()); }); await __testing.stopQaGatewayChildProcessTree(child, { gracefulTimeoutMs: 50, forceTimeoutMs: 1_000, }); expect(child.exitCode !== null || child.signalCode !== null).toBe(true); }); it("treats bind collisions as retryable gateway startup errors", () => { expect( __testing.isRetryableGatewayStartupError( "another gateway instance is already listening on ws://127.0.0.1:43124", ), ).toBe(true); expect( __testing.isRetryableGatewayStartupError( "failed to bind gateway socket on ws://127.0.0.1:43124: Error: listen EADDRINUSE", ), ).toBe(true); expect(__testing.isRetryableGatewayStartupError("gateway failed to become healthy")).toBe( false, ); }); it("treats startup token mismatches as retryable rpc startup errors", () => { expect( __testing.isRetryableRpcStartupError( "unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)", ), ).toBe(true); expect(__testing.isRetryableRpcStartupError("permission denied")).toBe(false); }); it("probes gateway health with a one-shot HEAD request through the SSRF guard", async () => { const release = vi.fn(async () => {}); fetchWithSsrFGuardMock.mockResolvedValue({ response: { ok: true }, release, }); await expect( __testing.fetchLocalGatewayHealth({ baseUrl: "http://127.0.0.1:43124", healthPath: "/readyz", }), ).resolves.toBe(true); expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith( expect.objectContaining({ url: "http://127.0.0.1:43124/readyz", init: expect.objectContaining({ method: "HEAD", headers: { connection: "close", }, signal: expect.any(AbortSignal), }), policy: { allowPrivateNetwork: true }, auditContext: "qa-lab-gateway-child-health", }), ); expect(release).toHaveBeenCalledTimes(1); }); it("preserves only sanitized gateway debug artifacts", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-preserve-src-")); const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-preserve-repo-")); cleanups.push(async () => { await rm(tempRoot, { recursive: true, force: true }); await rm(repoRoot, { recursive: true, force: true }); }); const stdoutLogPath = path.join(tempRoot, "gateway.stdout.log"); const stderrLogPath = path.join(tempRoot, "gateway.stderr.log"); const artifactDir = path.join(repoRoot, ".artifacts", "qa-e2e", "gateway-runtime"); await mkdir(path.dirname(artifactDir), { recursive: true }); await writeFile( stdoutLogPath, 'OPENCLAW_GATEWAY_TOKEN=qa-suite-token\nOPENAI_API_KEY="openai-live"\nurl=http://127.0.0.1:18789/#token=abc123', "utf8", ); await writeFile(stderrLogPath, "Authorization: Bearer secret+/token=123456", "utf8"); await mkdir(path.join(tempRoot, "state"), { recursive: true }); await writeFile(path.join(tempRoot, "state", "secret.txt"), "do-not-copy", "utf8"); await __testing.preserveQaGatewayDebugArtifacts({ preserveToDir: artifactDir, stdoutLogPath, stderrLogPath, tempRoot, repoRoot, }); expect((await readdir(artifactDir)).toSorted()).toEqual([ "README.txt", "gateway.stderr.log", "gateway.stdout.log", ]); await expect(readFile(path.join(artifactDir, "gateway.stdout.log"), "utf8")).resolves.toBe( "OPENCLAW_GATEWAY_TOKEN=\nOPENAI_API_KEY=\nurl=http://127.0.0.1:18789/#token=", ); await expect(readFile(path.join(artifactDir, "gateway.stderr.log"), "utf8")).resolves.toBe( "Authorization: Bearer ", ); await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain( "was not copied because it may contain credentials or auth tokens", ); }); it("rejects preserved gateway artifacts outside the repo root", async () => { await expect( __testing.assertQaArtifactDirWithinRepo("/tmp/openclaw-repo", "/tmp/outside"), ).rejects.toThrow("QA gateway artifact directory must stay within the repo root."); }); it("rejects preserved gateway artifacts that traverse symlinks", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-guard-repo-")); const outsideRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-guard-outside-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); await rm(outsideRoot, { recursive: true, force: true }); }); await mkdir(path.join(repoRoot, ".artifacts"), { recursive: true }); await symlink(outsideRoot, path.join(repoRoot, ".artifacts", "qa-e2e"), "dir"); await expect( __testing.assertQaArtifactDirWithinRepo( repoRoot, path.join(repoRoot, ".artifacts", "qa-e2e", "gateway-runtime"), ), ).rejects.toThrow("QA gateway artifact directory must not traverse symlinks."); }); it("cleans startup temp roots when they are not preserved", async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-cleanup-src-")); const stagedRoot = await mkdtemp(path.join(os.tmpdir(), "qa-gateway-cleanup-stage-")); cleanups.push(async () => { await rm(tempRoot, { recursive: true, force: true }); await rm(stagedRoot, { recursive: true, force: true }); }); await writeFile(path.join(tempRoot, "openclaw.json"), "{}", "utf8"); await writeFile(path.join(stagedRoot, "marker.txt"), "x", "utf8"); await __testing.cleanupQaGatewayTempRoots({ tempRoot, stagedBundledPluginsRoot: stagedRoot, }); await expect(lstat(tempRoot)).rejects.toMatchObject({ code: "ENOENT" }); await expect(lstat(stagedRoot)).rejects.toMatchObject({ code: "ENOENT" }); }); }); describe("resolveQaControlUiRoot", () => { it("returns the built control ui root when repo assets exist", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-control-ui-root-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); const controlUiRoot = path.join(repoRoot, "dist", "control-ui"); await mkdir(controlUiRoot, { recursive: true }); await writeFile(path.join(controlUiRoot, "index.html"), "", "utf8"); expect(resolveQaControlUiRoot({ repoRoot })).toBe(controlUiRoot); }); it("returns undefined when control ui is disabled or not built", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-control-ui-root-missing-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); expect(resolveQaControlUiRoot({ repoRoot })).toBeUndefined(); expect(resolveQaControlUiRoot({ repoRoot, controlUiEnabled: false })).toBeUndefined(); }); }); describe("qa bundled plugin dir", () => { 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 }); }); await mkdir(path.join(repoRoot, "dist", "extensions", "qa-channel"), { recursive: true, }); await writeFile( path.join(repoRoot, "dist", "extensions", "qa-channel", "package.json"), "{}", "utf8", ); await mkdir(path.join(repoRoot, "dist-runtime", "extensions", "qa-channel"), { recursive: true, }); await writeFile( path.join(repoRoot, "dist-runtime", "extensions", "qa-channel", "package.json"), "{}", "utf8", ); 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, "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("uses a source bundled plugin when the built copy is missing CLI metadata", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-cli-metadata-root-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); await mkdir(path.join(repoRoot, "dist", "extensions", "memory-core"), { recursive: true }); await writeFile( path.join(repoRoot, "dist", "extensions", "memory-core", "package.json"), "{}", "utf8", ); await writeFile( path.join(repoRoot, "dist", "extensions", "memory-core", "openclaw.plugin.json"), JSON.stringify({ id: "memory-core", kind: "memory" }), "utf8", ); await mkdir(path.join(repoRoot, "extensions", "memory-core"), { recursive: true }); await writeFile(path.join(repoRoot, "extensions", "memory-core", "package.json"), "{}", "utf8"); await writeFile( path.join(repoRoot, "extensions", "memory-core", "openclaw.plugin.json"), JSON.stringify({ id: "memory-core", kind: "memory" }), "utf8", ); await writeFile( path.join(repoRoot, "extensions", "memory-core", "cli-metadata.ts"), "export default { id: 'memory-core' };\n", "utf8", ); expect( __testing.resolveQaBundledPluginSourceDir({ repoRoot, pluginId: "memory-core", }), ).toBe(path.join(repoRoot, "extensions", "memory-core")); }); it("creates a scoped bundled plugin tree for allowed plugins plus always-allowed runtime facades", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-bundled-scope-")); 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 () => { await rm(tempRoot, { recursive: true, force: true }); }); const { bundledPluginsDir, stagedRoot } = await __testing.createQaBundledPluginsDir({ repoRoot, tempRoot, allowedPluginIds: ["qa-channel", "memory-core"], }); expect((await readdir(bundledPluginsDir)).toSorted()).toEqual([ "memory-core", "qa-channel", "speech-core", ]); expect(bundledPluginsDir).toBe( path.join( repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot), "dist", "extensions", ), ); 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); await expect( lstat( path.join( repoRoot, ".artifacts", "qa-runtime", path.basename(tempRoot), "dist", "shared-chunk-abc123.js", ), ), ).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 () => { await rm(repoRoot, { recursive: true, force: true }); }); await mkdir(path.join(repoRoot, "dist", "extensions", "openai"), { recursive: true }); await writeFile( path.join(repoRoot, "dist", "extensions", "openai", "openclaw.plugin.json"), JSON.stringify({ id: "openai", providers: ["openai", "openai-codex"], cliBackends: ["codex-cli"], }), "utf8", ); await expect( __testing.resolveQaOwnerPluginIdsForProviderIds({ repoRoot, providerIds: ["codex-cli"], }), ).resolves.toEqual(["openai"]); }); it("maps configured OpenAI Responses provider aliases to the OpenAI plugin", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-plugin-owner-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); await mkdir(path.join(repoRoot, "dist", "extensions", "openai"), { recursive: true }); await writeFile( path.join(repoRoot, "dist", "extensions", "openai", "openclaw.plugin.json"), JSON.stringify({ id: "openai", providers: ["openai"], cliBackends: ["codex-cli"], }), "utf8", ); await expect( __testing.resolveQaOwnerPluginIdsForProviderIds({ repoRoot, providerIds: ["custom-openai"], providerConfigs: { "custom-openai": { baseUrl: "https://api.example.test/v1", api: "openai-responses", models: [ { id: "model-a", name: "model-a", api: "openai-responses", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 4096, }, ], }, }, }), ).resolves.toEqual(["openai"]); }); it("copies selected live provider configs from the host config", async () => { const configPath = path.join( await mkdtemp(path.join(os.tmpdir(), "qa-provider-config-")), "openclaw.json", ); cleanups.push(async () => { await rm(path.dirname(configPath), { recursive: true, force: true }); }); await writeFile( configPath, JSON.stringify({ models: { providers: { "custom-openai": { baseUrl: "https://api.example.test/v1", api: "openai-responses", models: [ { id: "model-a", name: "model-a", api: "openai-responses", reasoning: true, input: ["text"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128_000, maxTokens: 4096, }, ], }, ignored: { baseUrl: "https://ignored.example.test/v1", api: "openai-responses", models: [], }, }, }, }), "utf8", ); await expect( __testing.readQaLiveProviderConfigOverrides({ providerIds: ["custom-openai"], env: { OPENCLAW_QA_LIVE_PROVIDER_CONFIG_PATH: configPath }, }), ).resolves.toEqual({ "custom-openai": expect.objectContaining({ baseUrl: "https://api.example.test/v1", api: "openai-responses", }), }); }); it("raises the QA runtime host version to the highest allowed plugin floor", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-runtime-version-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); await writeFile( path.join(repoRoot, "package.json"), JSON.stringify({ version: "2026.4.7-1" }), "utf8", ); const bundledRoot = path.join(repoRoot, "extensions"); await mkdir(path.join(bundledRoot, "qa-channel"), { recursive: true }); await writeFile( path.join(bundledRoot, "qa-channel", "package.json"), JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.8" } } }), "utf8", ); await mkdir(path.join(bundledRoot, "memory-core"), { recursive: true }); await writeFile( path.join(bundledRoot, "memory-core", "package.json"), JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.7" } } }), "utf8", ); await expect( __testing.resolveQaRuntimeHostVersion({ repoRoot, allowedPluginIds: ["memory-core", "qa-channel"], }), ).resolves.toBe("2026.4.8"); }); it("includes always-allowed runtime facade plugins when raising the QA runtime host version", async () => { const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-runtime-version-runtime-facade-")); cleanups.push(async () => { await rm(repoRoot, { recursive: true, force: true }); }); await writeFile( path.join(repoRoot, "package.json"), JSON.stringify({ version: "2026.4.7-1" }), "utf8", ); const bundledRoot = path.join(repoRoot, "extensions"); await mkdir(path.join(bundledRoot, "qa-channel"), { recursive: true }); await writeFile( path.join(bundledRoot, "qa-channel", "package.json"), JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.8" } } }), "utf8", ); await mkdir(path.join(bundledRoot, "speech-core"), { recursive: true }); await writeFile( path.join(bundledRoot, "speech-core", "package.json"), JSON.stringify({ openclaw: { install: { minHostVersion: ">=2026.4.9" } } }), "utf8", ); await expect( __testing.resolveQaRuntimeHostVersion({ repoRoot, allowedPluginIds: ["qa-channel"], }), ).resolves.toBe("2026.4.9"); }); });