From 1f91f218fc4e3e0acfa0297934db0b4fcde7db3a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 15 Apr 2026 20:21:19 -0400 Subject: [PATCH] fix: clean up QA temp roots on node lookup failure --- extensions/qa-lab/src/gateway-child.test.ts | 43 ++++++++++++++++++++- extensions/qa-lab/src/gateway-child.ts | 2 +- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/extensions/qa-lab/src/gateway-child.test.ts b/extensions/qa-lab/src/gateway-child.test.ts index 50bc9a23be6..f8e255c86de 100644 --- a/extensions/qa-lab/src/gateway-child.test.ts +++ b/extensions/qa-lab/src/gateway-child.test.ts @@ -4,18 +4,37 @@ 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> = []; afterEach(async () => { fetchWithSsrFGuardMock.mockReset(); + resolveQaNodeExecPathMock.mockReset(); + qaTempPathState.preferredTmpDir = process.env.TMPDIR || "/tmp"; while (cleanups.length > 0) { await cleanups.pop()?.(); } @@ -37,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(), diff --git a/extensions/qa-lab/src/gateway-child.ts b/extensions/qa-lab/src/gateway-child.ts index 80ed9dab005..a361bc878d2 100644 --- a/extensions/qa-lab/src/gateway-child.ts +++ b/extensions/qa-lab/src/gateway-child.ts @@ -820,9 +820,9 @@ export async function startQaGatewayChild(params: { let rpcClient: Awaited> | null = null; let stagedBundledPluginsRoot: string | null = null; let env: NodeJS.ProcessEnv | null = null; - const nodeExecPath = await resolveQaNodeExecPath(); 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}`;