diff --git a/openclaw.mjs b/openclaw.mjs index 2d39b1615606..fcc7cfb3a6a1 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { existsSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { access } from "node:fs/promises"; import module from "node:module"; import os from "node:os"; @@ -124,6 +124,23 @@ const resolvePackagedCompileCacheDirectory = () => { ); }; +const resolveCompileCacheRespawnLauncher = () => { + const moduleLauncher = fileURLToPath(import.meta.url); + const invokedLauncher = process.argv[1]; + if (invokedLauncher) { + try { + // npm/pnpm's lexical install path matters only when it identifies this module. + if (realpathSync(invokedLauncher) === realpathSync(moduleLauncher)) { + return invokedLauncher; + } + } catch { + // An unavailable entry path cannot identify this launcher. + } + } + // Public cli-entry imports can belong to another application or have no argv[1]. + return moduleLauncher; +}; + const respawnWithoutCompileCacheIfNeeded = () => { if (!isSourceCheckoutLauncher()) { return false; @@ -142,7 +159,7 @@ const respawnWithoutCompileCacheIfNeeded = () => { delete env.NODE_COMPILE_CACHE; return runRespawnedChild( process.execPath, - [...process.execArgv, fileURLToPath(import.meta.url), ...process.argv.slice(2)], + [...process.execArgv, resolveCompileCacheRespawnLauncher(), ...process.argv.slice(2)], env, ); }; @@ -169,8 +186,7 @@ const respawnWithPackagedCompileCacheIfNeeded = () => { }; return runRespawnedChild( process.execPath, - // pnpm's lexical hash link owns the install; its realpath is only shared package content. - [...process.execArgv, process.argv[1], ...process.argv.slice(2)], + [...process.execArgv, resolveCompileCacheRespawnLauncher(), ...process.argv.slice(2)], env, ); }; diff --git a/src/node-host/node-worker-supervisor.lifetime.test.ts b/src/node-host/node-worker-supervisor.lifetime.test.ts index 7246a6999125..cf7066338d43 100644 --- a/src/node-host/node-worker-supervisor.lifetime.test.ts +++ b/src/node-host/node-worker-supervisor.lifetime.test.ts @@ -4,7 +4,9 @@ import { createConnection } from "node:net"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { WORKER_PROTOCOL_MAX_INFERENCE_PAYLOAD_BYTES } from "../../packages/gateway-protocol/src/schema/worker-inference.js"; +import { createDeferred } from "../../test/helpers/promise.js"; import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import { racePromiseWithAbortSignal } from "../infra/abort-signal.js"; import { resetSecretRedactionRegistryForTest } from "../logging/secret-redaction-registry.test-support.js"; import { closeOpenClawStateDatabaseAsync, @@ -486,7 +488,9 @@ describe("node worker environment lifetime", () => { } }); - it("does not observe retirement before teardown, connection close, and definitive identity", async () => { + it("does not observe retirement before teardown, connection close, and definitive identity", async ({ + signal: testSignal, + }) => { const { supervisor, workspaceDir } = fixture({ capacity: 1 }); const first = testWorkerLaunchInput(workspaceDir, "held-first", "background-start"); const next = structuredClone(first); @@ -503,6 +507,7 @@ describe("node worker environment lifetime", () => { let restoreClose: (() => void) | undefined; let restoreIdentity: (() => void) | undefined; let releaseClose: (() => void) | undefined; + const closeCaptured = createDeferred(); let replacement: ReturnType | undefined; const releaseSignals = () => { restoreSignals?.(); @@ -531,6 +536,7 @@ describe("node worker environment lifetime", () => { const close = vi.spyOn(socket, "emit").mockImplementation((event, ...args) => { if (event === "close") { releaseClose = () => emit(event, ...args); + closeCaptured.resolve(); return true; } return emit(event, ...args); @@ -551,7 +557,9 @@ describe("node worker environment lifetime", () => { expect(assertRetired).toThrow(); releaseSignals(); - await vi.waitFor(() => expect(releaseClose).toBeDefined()); + // Join the owned close event; a polling deadline can precede real teardown. + await racePromiseWithAbortSignal(closeCaptured.promise, testSignal); + expect(releaseClose).toBeDefined(); await replacement; await waitForTerminal(supervisor, next.launchId); await vi.waitFor(() => { diff --git a/src/node-host/node-worker-supervisor.recovery.test.ts b/src/node-host/node-worker-supervisor.recovery.test.ts index a9c3e465e770..a03d91d5c02c 100644 --- a/src/node-host/node-worker-supervisor.recovery.test.ts +++ b/src/node-host/node-worker-supervisor.recovery.test.ts @@ -38,7 +38,10 @@ import { testWorkerLaunchInput, writeNodeWorkerFixture, } from "./node-worker-supervisor.test-support.js"; -import { inspectOwnedNodeWorkerTree } from "./node-worker-tree-control.js"; +import { + inspectOwnedNodeWorkerTree, + waitForOwnedNodeWorkerTreeDeath, +} from "./node-worker-tree-control.js"; import { NodeWorkerTurnStore } from "./node-worker-turn-store.js"; import { NodeWorkerWorkspaceProcesses } from "./node-worker-workspace-processes.js"; @@ -485,7 +488,26 @@ describe("node worker supervisor recovery", () => { initialization, closing, Promise.resolve().then(() => replacement.close()), - resumed.catch(() => undefined).then(() => waitForIdentityDeath(anchor)), + resumed + .catch(() => undefined) + .then(async () => { + if (operation !== "close") { + await waitForIdentityDeath(anchor); + return; + } + // Close releases the supervisor's observation, not the anchor's cleanup. + // Join its real retirement; loading the lineage writer can precede TERM grace. + expect(await waitForOwnedNodeWorkerTreeDeath(anchor)).toBe("dead"); + expect( + await new NodeWorkerLaunchStore(new NodeWorkerJournalWorker({ env })).get( + input.launchId, + ), + ).toMatchObject({ + state: "running", + worker: anchor, + workerLineageSettled: true, + }); + }), ]) ).flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); if (errors.length > 0) { diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index 835e60e47833..4008e8766096 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -8,15 +8,22 @@ import { pathToFileURL } from "node:url"; import { build as esbuild } from "esbuild"; import { afterEach, describe, expect, it } from "vitest"; import { parseNodeReleaseVersion } from "../node-version.mjs"; +import { + inspectManagedProcessGroup, + terminateManagedChild, + waitForManagedProcessGroupExit, +} from "../scripts/lib/managed-child-process.mts"; import { resolveTestNodeExecPath } from "../src/test-utils/node-process.js"; +import { createFixtureLifetime } from "./helpers/fixture-lifetime.js"; import { NODE_RELEASE_VERSION_CASES } from "./helpers/node-version-cases.js"; -import { cleanupTempDirs, makeTempDir } from "./helpers/temp-dir.js"; // Node version fixtures must enter Node admission even when Vitest runs under Bun. const testNodeExecPath = resolveTestNodeExecPath(); -async function makeLauncherFixture(fixtureRoots: string[]): Promise { - const fixtureRoot = makeTempDir(fixtureRoots, "openclaw-launcher-"); +async function makeLauncherFixture( + fixtures: ReturnType, +): Promise { + const fixtureRoot = fixtures.createTempDir("openclaw-launcher-"); await fs.copyFile( path.resolve(process.cwd(), "openclaw.mjs"), path.join(fixtureRoot, "openclaw.mjs"), @@ -139,6 +146,24 @@ async function waitForProcessExit( } } +async function settleLauncherFixture( + fixtures: ReturnType, + launcher: ReturnType, +): Promise { + await fixtures.verifyCleanup(async () => { + // The detached fixture owns the respawn group even before a PID file is ready. + if (inspectManagedProcessGroup(launcher, { errorPolicy: "alive-on-eperm" }) !== "dead") { + terminateManagedChild(launcher, "SIGKILL", { processGroupFallback: "never" }); + } + await waitForProcessExit(launcher, "fixture launcher cleanup", 5000); + if ( + !(await waitForManagedProcessGroupExit(launcher, 5000, { errorPolicy: "alive-on-eperm" })) + ) { + throw new Error("launcher fixture process group did not settle; retain fixture inputs"); + } + }); +} + function isProcessAlive(pid: number | undefined): boolean { if (!pid) { return false; @@ -180,11 +205,9 @@ function hasBunRuntime(): boolean { } describe("openclaw launcher", () => { - const fixtureRoots: string[] = []; + const fixtures = createFixtureLifetime(); - afterEach(async () => { - cleanupTempDirs(fixtureRoots); - }); + afterEach(() => fixtures.cleanup()); describe("browser native transport dispatch", () => { it("uses only the fixed native artifact before pending package repair or ordinary CLI startup", async () => { @@ -304,8 +327,8 @@ describe("openclaw launcher", () => { pendingLifecycle?: boolean; } = {}, ) { - const root = await makeLauncherFixture(fixtureRoots); - const home = makeTempDir(fixtureRoots, "openclaw-launcher-home with spaces-"); + const root = await makeLauncherFixture(fixtures); + const home = fixtures.createTempDir("openclaw-launcher-home with spaces-"); const nodePath = path.join( home, ".openclaw", @@ -468,7 +491,7 @@ describe("openclaw launcher", () => { }); }); - it.each(["n\n", "\n", "", "maybe\n", "\u0003"])( + it.each(["n\n", "\n", "", "maybe\n", "^C", "\u0003"])( "does not install after decline or cancellation: %j", async (input) => { const fixture = await prepareRecovery(); @@ -672,7 +695,7 @@ describe("openclaw launcher", () => { ["triage", "--json"], ["triage", "--non-interactive"], ])("admits packaged diagnostics on unsupported Node: %j", async (...args) => { - const root = await makeLauncherFixture(fixtureRoots); + const root = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(root, "package.json"), JSON.stringify({ name: "openclaw", version: "2026.9.3" }), @@ -700,7 +723,7 @@ describe("openclaw launcher", () => { }); it("admits lossless Node builds outside the support table while retaining the major floor", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), 'process.stdout.write("runtime-loaded\\n");\n', @@ -748,7 +771,7 @@ describe("openclaw launcher", () => { }); it("prints recovery guidance before legacy-incompatible modules can load", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const legacyRuntimePath = path.join(fixtureRoot, "mock-legacy-runtime.mjs"); await fs.writeFile( legacyRuntimePath, @@ -777,7 +800,7 @@ describe("openclaw launcher", () => { }); it("rejects Bun without node:sqlite even when its Node compatibility version is new enough", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), 'process.stdout.write("unexpected-bun-runtime\\n");\n', @@ -816,7 +839,7 @@ describe("openclaw launcher", () => { }); it("surfaces transitive entry import failures instead of masking them as missing dist", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), 'import "missing-openclaw-launcher-dep";\nexport {};\n', @@ -835,7 +858,7 @@ describe("openclaw launcher", () => { }); it("keeps the friendly launcher error for a truly missing entry build output", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { cwd: fixtureRoot, @@ -848,7 +871,7 @@ describe("openclaw launcher", () => { }); it("executes an entry.mjs-only compiled entry through the root launcher", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addCompiledMjsEntryFixture(fixtureRoot); const result = spawnSync( @@ -869,7 +892,7 @@ describe("openclaw launcher", () => { it.runIf(process.env.OPENCLAW_TEST_BUN_LAUNCHER === "1" && hasBunRuntime())( "gates the real Bun runtime on node:sqlite availability", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), "process.stdout.write('bun entry ran\\n');\n", @@ -906,7 +929,7 @@ describe("openclaw launcher", () => { ); it("uses precomputed root help when plugin config does not invalidate it", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ rootHelpText: "PRECOMPUTED help\n" }), @@ -933,7 +956,7 @@ describe("openclaw launcher", () => { { command: "secrets", metadataKey: "secretsHelpText" }, { command: "nodes", metadataKey: "nodesHelpText" }, ])("uses precomputed $command help before loading the runtime entry", async (params) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ [params.metadataKey]: `PRECOMPUTED ${params.command} help\n` }), @@ -962,7 +985,7 @@ describe("openclaw launcher", () => { it.each(["config", "doctor", "gateway", "models", "plugins", "sessions", "tasks"])( "uses precomputed %s help before loading the runtime entry", async (command) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ subcommandHelpText: { [command]: `PRECOMPUTED ${command} help\n` } }), @@ -990,7 +1013,7 @@ describe("openclaw launcher", () => { ); it("uses precomputed subcommand help with leading root options", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ subcommandHelpText: { models: "PRECOMPUTED models help\n" } }), @@ -1023,7 +1046,7 @@ describe("openclaw launcher", () => { ["--profile", "work", "--dev", "models", "--help"], ].map((args) => ({ args, invocation: args.join(" ") })), )("passes profile selection to the runtime before cached help: $invocation", async ({ args }) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ @@ -1049,7 +1072,7 @@ describe("openclaw launcher", () => { }); it("defers precomputed subcommand help to the runtime entry when container env is set", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ subcommandHelpText: { models: "PRECOMPUTED models help\n" } }), @@ -1103,7 +1126,7 @@ describe("openclaw launcher", () => { env: {}, }, ])("defers precomputed command help to the runtime entry with $name", async (params) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), JSON.stringify({ @@ -1134,7 +1157,7 @@ describe("openclaw launcher", () => { }); it("defers root help to the runtime entry when plugin config can change help", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const configPath = path.join(fixtureRoot, "openclaw.json"); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), @@ -1164,7 +1187,7 @@ describe("openclaw launcher", () => { }); it("defers nodes help to the runtime entry when plugin config can change help", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const configPath = path.join(fixtureRoot, "openclaw.json"); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), @@ -1198,7 +1221,7 @@ describe("openclaw launcher", () => { }); it("checks the OPENCLAW_HOME default config path before using precomputed root help", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const openclawHome = path.join(fixtureRoot, "home"); const configDir = path.join(openclawHome, ".openclaw"); await fs.mkdir(configDir, { recursive: true }); @@ -1230,7 +1253,7 @@ describe("openclaw launcher", () => { }); it("keeps literal $ patterns in HOME when expanding a tilde OPENCLAW_HOME", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const home = path.join(fixtureRoot, "home$&d"); const configDir = path.join(home, "oc", ".openclaw"); await fs.mkdir(configDir, { recursive: true }); @@ -1262,7 +1285,7 @@ describe("openclaw launcher", () => { }); it("checks legacy config candidates before using precomputed root help", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const home = path.join(fixtureRoot, "home"); const legacyConfigDir = path.join(home, ".clawdbot"); await fs.mkdir(legacyConfigDir, { recursive: true }); @@ -1294,7 +1317,7 @@ describe("openclaw launcher", () => { }); it("defers root help when the active config has includes", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); const configPath = path.join(fixtureRoot, "openclaw.json"); await fs.writeFile( path.join(fixtureRoot, "dist", "cli-startup-metadata.json"), @@ -1320,7 +1343,7 @@ describe("openclaw launcher", () => { }); it("explains how to recover from an unbuilt source install", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addSourceTreeMarker(fixtureRoot); const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { @@ -1337,7 +1360,7 @@ describe("openclaw launcher", () => { }); it("respawns source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addGitMarker(fixtureRoot); await addCompileCacheProbe(fixtureRoot); @@ -1355,18 +1378,120 @@ describe("openclaw launcher", () => { it.runIf(process.platform !== "win32")( "forwards SIGTERM to source-checkout compile-cache respawn children", - async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + async () => + fixtures.run(async () => { + const fixtureRoot = await makeLauncherFixture(fixtures); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + const signalPath = path.join(fixtureRoot, "sigterm-received.txt"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + 'process.title = "openclaw-launcher-sigterm-test-child";', + `process.on("SIGTERM", () => { writeFileSync(${JSON.stringify(signalPath)}, "SIGTERM\\n"); process.exit(0); });`, + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); + + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + detached: true, + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", + }); + let respawnChildPid: number | undefined; + + try { + const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000); + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({ + code: 0, + signal: null, + }); + await expect(fs.readFile(signalPath, "utf8")).resolves.toBe("SIGTERM\n"); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + await settleLauncherFixture(fixtures, launcher); + } + }), + ); + + it.runIf(process.platform !== "win32").each([true, false])( + "preserves foreground Gmail shutdown grace with compile cache (source=%s)", + async (sourceCheckout) => + fixtures.run(async () => { + const fixtureRoot = await makeLauncherFixture(fixtures); + if (sourceCheckout) { + await addGitMarker(fixtureRoot); + } + const readyPath = path.join(fixtureRoot, "gmail-ready.json"); + const stoppedPath = path.join(fixtureRoot, "gmail-stopped.txt"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + `process.on("SIGTERM", () => setTimeout(() => { writeFileSync(${JSON.stringify(stoppedPath)}, "stopped"); process.exit(0); }, 3025));`, + `writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ pid: process.pid }));`, + "setInterval(() => {}, 1000);", + ].join("\n"), + ); + const launcher = spawn( + process.execPath, + [ + path.join(fixtureRoot, "openclaw.mjs"), + "webhooks", + "--profile", + "fixture", + "gmail", + "run", + ], + { + detached: true, + cwd: fixtureRoot, + env: launcherEnv({ NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-cache") }), + stdio: "ignore", + }, + ); + let ownerPid: number | undefined; + try { + ownerPid = (await waitForJsonFile<{ pid: number }>(readyPath, 5000)).pid; + launcher.kill("SIGTERM"); + await expect(waitForProcessExit(launcher, "foreground Gmail", 5000)).resolves.toEqual({ + code: 0, + signal: null, + }); + await expect(fs.readFile(stoppedPath, "utf8")).resolves.toBe("stopped"); + expect(isProcessAlive(ownerPid)).toBe(false); + } finally { + await settleLauncherFixture(fixtures, launcher); + } + }), + ); + + it.runIf(process.platform !== "win32").each([ + { signal: "SIGINT" as const, target: "launcher" }, + { signal: "SIGTERM" as const, target: "launcher" }, + { signal: "SIGKILL" as const, target: "child" }, + ])("preserves $signal when the respawn $target is signaled", async (testCase) => + fixtures.run(async () => { + const fixtureRoot = await makeLauncherFixture(fixtures); await addGitMarker(fixtureRoot); const childInfoPath = path.join(fixtureRoot, "child-info.json"); - const signalPath = path.join(fixtureRoot, "sigterm-received.txt"); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), [ 'import { writeFileSync } from "node:fs";', - 'process.title = "openclaw-launcher-sigterm-test-child";', - `process.on("SIGTERM", () => { writeFileSync(${JSON.stringify(signalPath)}, "SIGTERM\\n"); process.exit(0); });`, `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + 'process.title = "openclaw-launcher-default-signal-test-child";', "setInterval(() => {}, 1000);", "", ].join("\n"), @@ -1374,6 +1499,7 @@ describe("openclaw launcher", () => { ); const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + detached: true, cwd: fixtureRoot, env: launcherEnv({ NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), @@ -1386,135 +1512,25 @@ describe("openclaw launcher", () => { const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000); respawnChildPid = childInfo.pid; - launcher.kill("SIGTERM"); + if (testCase.target === "launcher") { + launcher.kill(testCase.signal); + } else { + process.kill(respawnChildPid, testCase.signal); + } await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({ - code: 0, - signal: null, + code: null, + signal: testCase.signal, }); - await expect(fs.readFile(signalPath, "utf8")).resolves.toBe("SIGTERM\n"); expect(isProcessAlive(respawnChildPid)).toBe(false); } finally { - if (isProcessAlive(respawnChildPid)) { - process.kill(respawnChildPid!, "SIGKILL"); - } - if (isProcessAlive(launcher.pid)) { - process.kill(launcher.pid!, "SIGKILL"); - } + await settleLauncherFixture(fixtures, launcher); } - }, + }), ); - it.runIf(process.platform !== "win32").each([true, false])( - "preserves foreground Gmail shutdown grace with compile cache (source=%s)", - async (sourceCheckout) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - if (sourceCheckout) { - await addGitMarker(fixtureRoot); - } - const readyPath = path.join(fixtureRoot, "gmail-ready.json"); - const stoppedPath = path.join(fixtureRoot, "gmail-stopped.txt"); - await fs.writeFile( - path.join(fixtureRoot, "dist", "entry.js"), - [ - 'import { writeFileSync } from "node:fs";', - `process.on("SIGTERM", () => setTimeout(() => { writeFileSync(${JSON.stringify(stoppedPath)}, "stopped"); process.exit(0); }, 3025));`, - `writeFileSync(${JSON.stringify(readyPath)}, JSON.stringify({ pid: process.pid }));`, - "setInterval(() => {}, 1000);", - ].join("\n"), - ); - const launcher = spawn( - process.execPath, - [ - path.join(fixtureRoot, "openclaw.mjs"), - "webhooks", - "--profile", - "fixture", - "gmail", - "run", - ], - { - cwd: fixtureRoot, - env: launcherEnv({ NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-cache") }), - stdio: "ignore", - }, - ); - let ownerPid: number | undefined; - try { - ownerPid = (await waitForJsonFile<{ pid: number }>(readyPath, 5000)).pid; - launcher.kill("SIGTERM"); - await expect(waitForProcessExit(launcher, "foreground Gmail", 5000)).resolves.toEqual({ - code: 0, - signal: null, - }); - await expect(fs.readFile(stoppedPath, "utf8")).resolves.toBe("stopped"); - expect(isProcessAlive(ownerPid)).toBe(false); - } finally { - for (const pid of [ownerPid, launcher.pid]) { - if (isProcessAlive(pid)) { - process.kill(pid!, "SIGKILL"); - } - } - } - }, - ); - - it.runIf(process.platform !== "win32").each([ - { signal: "SIGINT" as const, target: "launcher" }, - { signal: "SIGTERM" as const, target: "launcher" }, - { signal: "SIGKILL" as const, target: "child" }, - ])("preserves $signal when the respawn $target is signaled", async (testCase) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - await addGitMarker(fixtureRoot); - const childInfoPath = path.join(fixtureRoot, "child-info.json"); - await fs.writeFile( - path.join(fixtureRoot, "dist", "entry.js"), - [ - 'import { writeFileSync } from "node:fs";', - `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, - 'process.title = "openclaw-launcher-default-signal-test-child";', - "setInterval(() => {}, 1000);", - "", - ].join("\n"), - "utf8", - ); - - const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { - cwd: fixtureRoot, - env: launcherEnv({ - NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), - }), - stdio: "ignore", - }); - let respawnChildPid: number | undefined; - - try { - const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000); - respawnChildPid = childInfo.pid; - - if (testCase.target === "launcher") { - launcher.kill(testCase.signal); - } else { - process.kill(respawnChildPid, testCase.signal); - } - - await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({ - code: null, - signal: testCase.signal, - }); - expect(isProcessAlive(respawnChildPid)).toBe(false); - } finally { - if (isProcessAlive(respawnChildPid)) { - process.kill(respawnChildPid!, "SIGKILL"); - } - if (isProcessAlive(launcher.pid)) { - process.kill(launcher.pid!, "SIGKILL"); - } - } - }); - it("preserves an explicit exit 143 from a compile-cache respawn child", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addGitMarker(fixtureRoot); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), @@ -1534,89 +1550,174 @@ describe("openclaw launcher", () => { it.runIf(process.platform !== "win32")( "exits after SIGTERM when the respawn child ignores the forwarded signal", - async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - await addGitMarker(fixtureRoot); - const childInfoPath = path.join(fixtureRoot, "child-info.json"); - await fs.writeFile( - path.join(fixtureRoot, "dist", "entry.js"), - [ - 'import { writeFileSync } from "node:fs";', - 'process.title = "openclaw-launcher-sigterm-ignore-test-child";', - 'process.on("SIGTERM", () => {});', - `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, - "setInterval(() => {}, 1000);", - "", - ].join("\n"), - "utf8", - ); + async () => + fixtures.run(async () => { + const fixtureRoot = await makeLauncherFixture(fixtures); + await addGitMarker(fixtureRoot); + const childInfoPath = path.join(fixtureRoot, "child-info.json"); + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import { writeFileSync } from "node:fs";', + 'process.title = "openclaw-launcher-sigterm-ignore-test-child";', + 'process.on("SIGTERM", () => {});', + `writeFileSync(${JSON.stringify(childInfoPath)}, JSON.stringify({ pid: process.pid }) + "\\n");`, + "setInterval(() => {}, 1000);", + "", + ].join("\n"), + "utf8", + ); - const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { - cwd: fixtureRoot, - env: launcherEnv({ - NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), - }), - stdio: "ignore", - }); - let respawnChildPid: number | undefined; - - try { - const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000); - respawnChildPid = childInfo.pid; - - launcher.kill("SIGTERM"); - - await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({ - code: null, - signal: "SIGKILL", + const launcher = spawn(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { + detached: true, + cwd: fixtureRoot, + env: launcherEnv({ + NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache"), + }), + stdio: "ignore", }); - expect(isProcessAlive(launcher.pid)).toBe(false); - expect(isProcessAlive(respawnChildPid)).toBe(false); - } finally { - if (isProcessAlive(respawnChildPid)) { - process.kill(respawnChildPid!, "SIGKILL"); + let respawnChildPid: number | undefined; + + try { + const childInfo = await waitForJsonFile<{ pid: number }>(childInfoPath, 5000); + respawnChildPid = childInfo.pid; + + launcher.kill("SIGTERM"); + + await expect(waitForProcessExit(launcher, "launcher", 5000)).resolves.toEqual({ + code: null, + signal: "SIGKILL", + }); + expect(isProcessAlive(launcher.pid)).toBe(false); + expect(isProcessAlive(respawnChildPid)).toBe(false); + } finally { + await settleLauncherFixture(fixtures, launcher); } - if (isProcessAlive(launcher.pid)) { - process.kill(launcher.pid!, "SIGKILL"); - } - } - }, + }), ); it.runIf(process.platform !== "win32")( "respawns symlinked source-checkout launchers without inherited NODE_COMPILE_CACHE", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addGitMarker(fixtureRoot); await addCompileCacheProbe(fixtureRoot); - const linkParent = makeTempDir(fixtureRoots, "openclaw-launcher-link-"); - const linkedRoot = path.join(linkParent, "openclaw-linked"); - await fs.symlink(fixtureRoot, linkedRoot, "dir"); + const linkParent = fixtures.createTempDir("openclaw-launcher-link-"); + await fs.appendFile( + path.join(fixtureRoot, "dist", "entry.js"), + '\nprocess.stdout.write("\\n" + process.argv[1]);\n', + ); + for (const prefix of ["invoked", "on-path"]) { + const root = path.join(linkParent, prefix); + await fs.mkdir(path.join(root, "lib", "node_modules"), { recursive: true }); + await fs.mkdir(path.join(root, "bin")); + await fs.symlink(fixtureRoot, path.join(root, "lib", "node_modules", "openclaw"), "dir"); + await fs.symlink( + "../lib/node_modules/openclaw/openclaw.mjs", + path.join(root, "bin", "openclaw"), + ); + } + const launcher = path.join(linkParent, "invoked", "bin", "openclaw"); - const result = spawnSync(process.execPath, [path.join(linkedRoot, "openclaw.mjs")], { + const result = spawnSync(process.execPath, [launcher], { cwd: linkParent, env: launcherEnv({ NODE_COMPILE_CACHE: path.join(linkParent, ".node-compile-cache"), + PATH: [path.join(linkParent, "on-path", "bin"), process.env.PATH].join(path.delimiter), }), encoding: "utf8", }); expect(result.status).toBe(0); - expect(result.stdout).toBe("cache:disabled;respawn:1"); + expect(result.stdout).toBe(`cache:disabled;respawn:1\n${launcher}`); }, ); + it.each([ + ["source", "import"], + ["packaged", "import"], + ["source", "preload-with-entry"], + ["packaged", "preload-with-entry"], + ["source", "preload-without-entry"], + ["packaged", "preload-without-entry"], + ] as const)("a %s cli-entry %s restarts the actual launcher", async (kind, invocation) => { + const fixtureRoot = await makeLauncherFixture(fixtures); + await fs.writeFile( + path.join(fixtureRoot, "package.json"), + JSON.stringify({ + name: "openclaw", + type: "module", + version: "2026.8.1", + exports: { "./cli-entry": "./openclaw.mjs" }, + }), + ); + if (kind === "source") { + await addSourceTreeMarker(fixtureRoot); + } + await fs.writeFile( + path.join(fixtureRoot, "dist", "entry.js"), + [ + 'import module from "node:module";', + "process.stdout.write(JSON.stringify({", + " launcher: process.argv[1],", + " args: process.argv.slice(2),", + ' cache: module.getCompileCacheDir?.() ? "enabled" : "disabled",', + ' sourceRespawn: process.env.OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED ?? "0",', + ' packagedRespawn: process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED ?? "0",', + "}));", + ].join("\n"), + ); + const hostMarker = path.join(fixtureRoot, "host-executions.txt"); + const host = path.join(fixtureRoot, "host.mjs"); + if (invocation !== "preload-without-entry") { + await fs.writeFile( + host, + invocation === "import" + ? [ + 'import fs from "node:fs";', + `fs.appendFileSync(${JSON.stringify(hostMarker)}, "host\\n");`, + 'await import("openclaw/cli-entry");', + ].join("\n") + : 'await import("openclaw/cli-entry");', + ); + } + const args = + invocation === "import" + ? [host, "proof-argument"] + : invocation === "preload-with-entry" + ? ["--import", "openclaw/cli-entry", host, "proof-argument"] + : ["--import", "openclaw/cli-entry"]; + const result = spawnSync(testNodeExecPath, args, { + cwd: fixtureRoot, + env: launcherEnv({ NODE_COMPILE_CACHE: path.join(fixtureRoot, ".node-compile-cache") }), + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual({ + launcher: path.join(fixtureRoot, "openclaw.mjs"), + args: invocation === "preload-without-entry" ? [] : ["proof-argument"], + cache: kind === "source" ? "disabled" : "enabled", + sourceRespawn: kind === "source" ? "1" : "0", + packagedRespawn: kind === "packaged" ? "1" : "0", + }); + if (invocation === "import") { + expect(await fs.readFile(hostMarker, "utf8")).toBe("host\n"); + } + }); + it.runIf(process.platform !== "win32")( "preserves a packaged pnpm project path through compile-cache respawn", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.8.1"}\n'); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), 'process.stdout.write(process.argv[1] ?? "");\n', "utf8", ); - const globalRoot = makeTempDir(fixtureRoots, "openclaw-pnpm-global-"); + const globalRoot = fixtures.createTempDir("openclaw-pnpm-global-"); const packageRoot = path.join(globalRoot, "v11", "active", "node_modules", "openclaw"); await fs.mkdir(path.dirname(packageRoot), { recursive: true }); await fs.symlink(fixtureRoot, packageRoot, "dir"); @@ -1634,7 +1735,7 @@ describe("openclaw launcher", () => { ); it("keeps compile cache enabled for packaged launchers when NODE_COMPILE_CACHE is configured", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await addCompileCacheProbe(fixtureRoot); const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { @@ -1652,7 +1753,7 @@ describe("openclaw launcher", () => { it.runIf(process.platform !== "win32")( "does not respawn native hook relays for packaged compile-cache scoping", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.4.29"}\n'); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), @@ -1678,7 +1779,7 @@ describe("openclaw launcher", () => { ); it("scopes packaged launcher compile cache inside configured cache roots", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); + const fixtureRoot = await makeLauncherFixture(fixtures); await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.4.29"}\n'); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), @@ -1702,9 +1803,9 @@ describe("openclaw launcher", () => { }); it("falls back to the default packaged launcher compile cache when NODE_COMPILE_CACHE is empty", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - const runCwd = makeTempDir(fixtureRoots, "openclaw-launcher-cwd-"); - const tmpRoot = makeTempDir(fixtureRoots, "openclaw-launcher-tmp-"); + const fixtureRoot = await makeLauncherFixture(fixtures); + const runCwd = fixtures.createTempDir("openclaw-launcher-cwd-"); + const tmpRoot = fixtures.createTempDir("openclaw-launcher-tmp-"); await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.4.29"}\n'); await fs.writeFile( path.join(fixtureRoot, "dist", "entry.js"), @@ -1740,8 +1841,8 @@ describe("openclaw launcher", () => { "disabled-empty", "cache-empty", ])("shares the first-success compile cache base with bundled helpers: %s", async (mode) => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - const tmpRoot = makeTempDir(fixtureRoots, "openclaw-launcher-cache-"); + const fixtureRoot = await makeLauncherFixture(fixtures); + const tmpRoot = fixtures.createTempDir("openclaw-launcher-cache-"); await fs.writeFile(path.join(fixtureRoot, "package.json"), '{"version":"2026.9.6"}\n'); if (mode === "source") { await addGitMarker(fixtureRoot); @@ -1854,8 +1955,8 @@ describe("openclaw launcher", () => { }); it("enables compile cache for packaged launchers", async () => { - const fixtureRoot = await makeLauncherFixture(fixtureRoots); - const tmpRoot = makeTempDir(fixtureRoots, "openclaw-launcher-tmp-"); + const fixtureRoot = await makeLauncherFixture(fixtures); + const tmpRoot = fixtures.createTempDir("openclaw-launcher-tmp-"); await addCompileCacheProbe(fixtureRoot); const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs")], { diff --git a/test/scripts/pr-main-refresh.test-support.ts b/test/scripts/pr-main-refresh.test-support.ts index f60fdd37cd37..d71843ed63fe 100644 --- a/test/scripts/pr-main-refresh.test-support.ts +++ b/test/scripts/pr-main-refresh.test-support.ts @@ -12,6 +12,7 @@ import { delimiter, join } from "node:path"; import { pathToFileURL } from "node:url"; import { afterAll } from "vitest"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; +import { createPrivateHandoffStoreFixture } from "./pr-private-handoff.test-support.js"; import { copyPrWrapperSources, linkPrWrapperDependencies } from "./pr-wrapper.test-support.js"; const templateDirs = useAutoCleanupTempDirTracker(afterAll); @@ -24,8 +25,10 @@ function shellQuote(value: string): string { function createFixtureGit(root: string) { const home = join(root, "home"); mkdirSync(home); + const handoff = createPrivateHandoffStoreFixture(home); const env: NodeJS.ProcessEnv = { - PATH: process.env.PATH, + ...handoff.env, + PATH: handoff.env.PATH, HOME: home, TMPDIR: root, GIT_CONFIG_GLOBAL: "/dev/null", @@ -45,7 +48,7 @@ function createFixtureGit(root: string) { } return result.stdout.trim(); } - return { env, realGit, git }; + return { env, realGit, git, handoff }; } function createMainRefreshTemplate(directory: string, perWorktreeConfig: boolean) { @@ -125,7 +128,8 @@ export function createMainRefreshFixture( const worktree = join(canonical, ".worktrees", "pr-42"); const bin = join(root, "bin"); mkdirSync(bin); - const { env, realGit, git } = createFixtureGit(root); + const { env, realGit, git, handoff } = createFixtureGit(root); + const privateNodeOptions = env.NODE_OPTIONS; const { main, head, sameTreeHead, movedMain, gateMain } = template; const copyOptions = { recursive: true, mode: fsConstants.COPYFILE_FICLONE }; cpSync(template.origin, origin, copyOptions); @@ -748,6 +752,7 @@ if (process.argv[1]?.endsWith('/watch-pr-ci.mts')) { gateMain, env, git, + assertPrivateHandoffVerified: () => handoff.assertProvisionersInjected(), metadata, seedPreparedMerge() { // Merge-only cases need prepared inputs, not another prepare/gates/push run. @@ -770,11 +775,10 @@ if (process.argv[1]?.endsWith('/watch-pr-ci.mts')) { }, configure(update: Partial) { Object.assign(control, update); - if (control.hostedCi === "scheduled") { - delete env.NODE_OPTIONS; - } else { - env.NODE_OPTIONS = `--import=${clock}`; - } + env.NODE_OPTIONS = + control.hostedCi === "scheduled" + ? privateNodeOptions + : `${privateNodeOptions} --import=${pathToFileURL(clock).href}`; writeFileSync(controlFile, JSON.stringify(control)); }, events() { diff --git a/test/scripts/pr-main-refresh.test.ts b/test/scripts/pr-main-refresh.test.ts index 2e0dec24e8d9..1c78b5bf0e3a 100644 --- a/test/scripts/pr-main-refresh.test.ts +++ b/test/scripts/pr-main-refresh.test.ts @@ -739,9 +739,12 @@ ${readFileSync(gitShim, "utf8")} f.git(f.canonical, "update-ref", "-d", "refs/remotes/origin/main"); f.configure({ failFetchAt: fetchNumber }); const failed = f.run("review-checkout-main"); - expect(failed.status).not.toBe(0); - expect(existsSync(f.worktree)).toBe(fetchNumber === 2); + const diagnostic = `stdout:\n${failed.stdout.slice(-4000)}\nstderr:\n${failed.stderr.slice(-4000)}`; + expect(failed.status, diagnostic).not.toBe(0); + expect(failed.stderr, diagnostic).toContain("fatal: injected main fetch failure"); + expect(existsSync(f.worktree), diagnostic).toBe(fetchNumber === 2); if (fetchNumber === 2) { + f.assertPrivateHandoffVerified(); expect(f.git(f.worktree, "symbolic-ref", "HEAD")).toBe("refs/heads/temp/pr-42"); expect(f.git(f.canonical, "rev-parse", "refs/heads/temp/pr-42")).toBe(f.main); expect(f.git(f.worktree, "write-tree")).toBe( @@ -755,11 +758,37 @@ ${readFileSync(gitShim, "utf8")} f.configure({ failFetchAt: 0 }); const result = f.run("review-checkout-main"); expect(result.status, result.stdout + result.stderr).toBe(0); + f.assertPrivateHandoffVerified(); expect(f.git(f.worktree, "rev-parse", "HEAD")).toBe(f.main); expect(f.git(f.canonical, "rev-parse", "HEAD")).toBe(f.main); }, ); + it("rejects a later uninjected provisioner despite an earlier valid receipt", () => { + const f = fixture(); + f.git(f.canonical, "worktree", "remove", "--force", f.worktree); + const first = f.run("review-checkout-main"); + expect(first.status, first.stdout + first.stderr).toBe(0); + f.assertPrivateHandoffVerified(); + + f.git(f.canonical, "worktree", "remove", "--force", f.worktree); + const nodeOptions = f.env.NODE_OPTIONS; + delete f.env.NODE_OPTIONS; + try { + const second = f.run("review-checkout-main"); + expect(second.status, second.stdout + second.stderr).not.toBe(0); + expect(second.stderr).toContain("Missing private handoff preload"); + expect(existsSync(f.worktree)).toBe(false); + expect(() => f.assertPrivateHandoffVerified()).toThrow( + "not every launched provisioner received its private store", + ); + } finally { + f.env.NODE_OPTIONS = nodeOptions; + } + const owner = f.git(f.canonical, "rev-parse", "refs/openclaw/pr-operation-locks/42"); + recoverFixtureLock(f, owner); + }); + it("invalidates the previous snapshot when the same operation provisions a new worktree", () => { const f = fixture(); f.configure({ moveAfterFirstFetch: true }); diff --git a/test/scripts/pr-merge-outcome.test.ts b/test/scripts/pr-merge-outcome.test.ts index 1fefba03e9d5..0bc1019a3db9 100644 --- a/test/scripts/pr-merge-outcome.test.ts +++ b/test/scripts/pr-merge-outcome.test.ts @@ -401,7 +401,7 @@ describePosix("native merge outcome with real Git and supervised lock recovery", describePosix("merge_outcome_repo_identity", () => { // Local historical records contain either scalar. Remote admission separately // binds the whole retained object to the authoritative repository pair. - const identity = (repo: unknown) => + const identity = (repo: unknown, parentEnv: NodeJS.ProcessEnv = process.env) => spawnSync( "bash", [ @@ -411,30 +411,33 @@ describePosix("merge_outcome_repo_identity", () => { join(scripts, "pr-lib/merge-outcome.sh"), JSON.stringify(repo), ], - { encoding: "utf8" }, + { + encoding: "utf8", + env: { ...parentEnv, OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: undefined }, + }, ); - it("accepts a historical numeric repository id", () => { - const run = identity({ - id: 1103012935, - nameWithOwner: "openclaw/openclaw", - url: "https://github.com/openclaw/openclaw", - }); - expect(run.status, run.stderr).toBe(0); - expect(JSON.parse(run.stdout).id).toBe(1103012935); - }); - - it("accepts a GraphQL node string repository id", () => { - const run = identity({ - id: "R_kgDOQb6kRw", - nameWithOwner: "openclaw/openclaw", - url: "https://github.com/openclaw/openclaw", - }); - expect(run.status, run.stderr).toBe(0); - }); + it.each([1103012935, "R_kgDOQb6kRw"])( + "validates identity %s despite an unrelated inherited helper snapshot", + (id) => { + const repo = { + id, + nameWithOwner: "openclaw/openclaw", + url: "https://github.com/openclaw/openclaw", + }; + const run = identity(repo, { + ...process.env, + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: join(scripts, "pr-lib"), + }); + expect(run.status, run.stderr).toBe(0); + expect(JSON.parse(run.stdout)).toEqual(repo); + expect(run.stderr).toBe(""); + }, + ); it.each([ - ["a missing id", { id: null }], + ["a missing id", {}], + ["a null id", { id: null }], ["an empty string id", { id: "" }], ["an object id", { id: { node: "x" } }], ])("still rejects %s", (_label, overrides) => { @@ -443,7 +446,8 @@ describePosix("merge_outcome_repo_identity", () => { url: "https://github.com/openclaw/openclaw", ...overrides, }); - expect(run.status).not.toBe(0); + expect(run.status, run.stderr).toBe(4); + expect(run.stderr).toBe(""); expect(run.stdout).toBe(""); }); @@ -453,7 +457,8 @@ describePosix("merge_outcome_repo_identity", () => { nameWithOwner: "openclaw/openclaw", url: "https://github.com/attacker/openclaw", }); - expect(run.status).not.toBe(0); + expect(run.status, run.stderr).toBe(4); + expect(run.stderr).toBe(""); expect(run.stdout).toBe(""); }); }); diff --git a/test/scripts/pr-merge.test.ts b/test/scripts/pr-merge.test.ts index 6be74d6824b3..b78fa655c2c3 100644 --- a/test/scripts/pr-merge.test.ts +++ b/test/scripts/pr-merge.test.ts @@ -40,10 +40,11 @@ type BodyScenario = { overrideBody?: string; }; -function prepareBody(scenario: BodyScenario) { +function prepareBody(scenario: BodyScenario, parentEnv: NodeJS.ProcessEnv = process.env) { const root = tempDirs.make("openclaw-merge-attribution-"); const sourceRepo = join(root, "source"); const authorTrace = join(root, "author-requests"); + const failureTrace = join(root, "injected-failures"); const trailerMarker = join(root, "trailer-command-called"); const body = join(root, "body"); const override = join(root, "operator body.md"); @@ -197,6 +198,7 @@ function prepareBody(scenario: BodyScenario) { // itself is inside another repository, so the body belongs in sourceRepo. mkdirSync(join(sourceRepo, ".local")); writeFileSync(authorTrace, ""); + writeFileSync(failureTrace, ""); writeFileSync( join(root, "gh"), `#!/usr/bin/env node @@ -209,7 +211,10 @@ if (args[0] !== "api" || args[1] !== "--hostname" || args[2] !== "fixture.github const endpoint = new URL(args[3], "https://fixture.github.invalid/"); if (endpoint.pathname !== "/repos/fixture/repo/commits") throw new Error("Unexpected API endpoint"); fs.appendFileSync(process.env.BODY_AUTHOR_TRACE, JSON.stringify(args) + "\\n"); -if (process.env.BODY_AUTHOR_READ_ERROR === "true") process.exit(1); +if (process.env.BODY_AUTHOR_READ_ERROR === "true") { + fs.appendFileSync(process.env.BODY_FAILURE_TRACE, "author-read\\n"); + process.exit(1); +} const sha = endpoint.searchParams.get("sha"); const limit = Number(endpoint.searchParams.get("per_page")); const commits = JSON.parse(process.env.BODY_COMMITS); @@ -218,6 +223,7 @@ let page = git(["rev-list", "--max-count=" + limit, sha]).split("\\n").map((oid) sha: oid, commit: { author: { name: "Unselected Author", email: "unselected@example.com" } }, author: { login: "unselected", type: "User" }, }); +if (process.env.BODY_AUTHOR_FAULT) fs.appendFileSync(process.env.BODY_FAILURE_TRACE, "author-" + process.env.BODY_AUTHOR_FAULT + "\\n"); switch (process.env.BODY_AUTHOR_FAULT) { case "empty": page = []; break; case "malformed": page = null; break; @@ -236,16 +242,16 @@ LOCAL_PREP_HEAD_SHA="$BODY_LOCAL_HEAD" MERGE_REPO_NAME=fixture/repo MERGE_REPO_HOST=fixture.github.invalid pr_git() { - if [ "$BODY_READ_ERROR" = true ] && [[ " $* " = *" log "* ]]; then return 1; fi + if [ "$BODY_READ_ERROR" = true ] && [[ " $* " = *" log "* ]]; then printf 'source-read\\n' >> "$BODY_FAILURE_TRACE"; return 1; fi command git -C "$BODY_SOURCE_REPO" "$@" } PR_MAIN_SHA=$(git rev-parse --verify refs/remotes/origin/main) merge_read() { [ "$*" = "preview 123" ] || return 99 - [ "$BODY_PREVIEW_ERROR" = false ] || return 1 + [ "$BODY_PREVIEW_ERROR" = false ] || { printf 'preview-read\\n' >> "$BODY_FAILURE_TRACE"; return 1; } printf '%s\\n' "$BODY_PREVIEW" } -mktemp() { [ "$BODY_WRITE_ERROR" = false ] || return 1; command mktemp "$@"; } +mktemp() { [ "$BODY_WRITE_ERROR" = false ] || { printf 'body-write\\n' >> "$BODY_FAILURE_TRACE"; return 1; }; command mktemp "$@"; } snapshot="" [ -z "$BODY_OVERRIDE" ] || snapshot=$(snapshot_merge_body "$BODY_OVERRIDE") file=$(prepare_squash_merge_body 123 "$snapshot") @@ -255,7 +261,10 @@ file=$(prepare_squash_merge_body 123 "$snapshot") cwd: sourceRepo, encoding: "utf8", env: { - ...process.env, + ...parentEnv, + // This fixture sources candidate code, not the supervising wrapper snapshot. + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: undefined, + OPENCLAW_GH_BIN: join(root, "gh"), PATH: `${root}:${process.env.PATH}`, ...(scenario.configuredTrailer ? { @@ -287,6 +296,7 @@ file=$(prepare_squash_merge_body 123 "$snapshot") BODY_AUTHOR_READ_ERROR: String(scenario.authorReadError ?? false), BODY_AUTHOR_FAULT: scenario.authorReadFault ?? "", BODY_AUTHOR_TRACE: authorTrace, + BODY_FAILURE_TRACE: failureTrace, BODY_COMMITS: JSON.stringify(githubCommits), BODY_PREVIEW: JSON.stringify({ transport: scenario.restPreview ? "rest" : "graphql", @@ -315,6 +325,7 @@ file=$(prepare_squash_merge_body 123 "$snapshot") ...result, mergeBody: existsSync(body) ? readFileSync(body, "utf8") : null, trailerCommandCalled: existsSync(trailerMarker), + injectedFailures: readFileSync(failureTrace, "utf8").trim().split("\n").filter(Boolean), authorRequests: readFileSync(authorTrace, "utf8") .split("\n") .filter(Boolean) @@ -323,6 +334,21 @@ file=$(prepare_squash_merge_body 123 "$snapshot") } describePosix("native squash attribution", () => { + it("composes the real body despite unrelated inherited snapshot and gh selectors", () => { + const result = prepareBody( + { sourceMessages: ["Repair"] }, + { + ...process.env, + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: tempDirs.make("unrelated-merge-snapshot-"), + OPENCLAW_GH_BIN: join(tempDirs.make("unrelated-gh-selector-"), "must-not-run"), + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(result.mergeBody).toBe( + "Server description\n\nCo-authored-by: Maintainer \n", + ); + }); + it.each([ { title: "COMMIT_OR_PR_TITLE", @@ -1173,7 +1199,9 @@ describePosix("native squash attribution", () => { mkdirSync(input); } if (kind === "symlink") { - symlinkSync(join(root, "target"), input); + const target = join(root, "target"); + writeFileSync(target, "Valid merge body\n"); + symlinkSync(target, input); } if (kind === "fifo") { expect(spawnSync("mkfifo", [input]).status).toBe(0); @@ -1191,6 +1219,7 @@ describePosix("native squash attribution", () => { ); expect(result.status, result.stderr).toBe(1); expect(result.stdout).toBe(""); + expect(result.stderr).toContain("Cannot prepare merge body:"); }, ); @@ -1296,8 +1325,28 @@ describePosix("native squash attribution", () => { overrideBody, ...failure, }); - expect(result.status).toBe(1); + expect(result.status, result.stderr).toBe(1); expect(result.mergeBody).toBeNull(); + const injectedFailure = failure.sourceReadError + ? "source-read" + : failure.authorReadError + ? "author-read" + : failure.authorReadFault + ? `author-${failure.authorReadFault}` + : failure.previewError + ? "preview-read" + : failure.bodyWriteError + ? "body-write" + : undefined; + if (injectedFailure) { + expect(result.injectedFailures).toContain(injectedFailure); + } else if (failure.previewQueue) { + expect(result.stderr).toContain("body overrides require a non-queue PR"); + } else if (failure.restPreview) { + expect(result.stderr).toContain("Cannot prepare merge body:"); + } else { + expect(result.stderr).toContain("require a current-head preview"); + } } }); }); diff --git a/test/scripts/pr-metadata.test.ts b/test/scripts/pr-metadata.test.ts index b3a4ed18e593..fe0d0db136ee 100644 --- a/test/scripts/pr-metadata.test.ts +++ b/test/scripts/pr-metadata.test.ts @@ -60,7 +60,11 @@ type Fixture = { graphqlQuota?: boolean; }; -function readPrMetadata(fixture: Fixture = {}, command = "pr_meta_json 42") { +function readPrMetadata( + fixture: Fixture = {}, + command = "pr_meta_json 42", + parentEnv: NodeJS.ProcessEnv = process.env, +) { const dir = tempDirs.make("openclaw-pr-metadata-"); const gh = join(dir, "gh"); const trace = join(dir, "trace"); @@ -261,7 +265,10 @@ if (endpoint === "user") { { cwd: process.cwd(), env: { - ...process.env, + ...parentEnv, + // This unsupervised child owns neither the parent's snapshot nor its FD3. + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: undefined, + OPENCLAW_PR_LOCK_NOTIFY_FD: undefined, FAKE_GH_FIXTURE: JSON.stringify(fixture), PR_GH_WRITER_LOGIN: "untrusted-inherited-login", PR_GH_WRITER_CONTEXT: "untrusted-inherited-context", @@ -306,6 +313,20 @@ if (endpoint === "user") { } describe("PR metadata through REST", () => { + it("reads real metadata with an unrelated inherited snapshot and closed notify FD", () => { + const result = readPrMetadata({}, "pr_meta_json 42", { + ...process.env, + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: tempDirs.make("unrelated-metadata-snapshot-"), + OPENCLAW_PR_LOCK_NOTIFY_FD: "3", + }); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ number: 42, headRefOid: head }); + expect( + result.calls.filter((args) => args.includes("repos/base-owner/base-repo/pulls/42")), + ).toHaveLength(2); + expect(result.notifications).toBe(""); + }); + describe("core quota fallback", () => { const repository = { id: "R_base", diff --git a/test/scripts/pr-operation-lock.test.ts b/test/scripts/pr-operation-lock.test.ts index 29b97bc050d1..e0ed9cff41a9 100644 --- a/test/scripts/pr-operation-lock.test.ts +++ b/test/scripts/pr-operation-lock.test.ts @@ -32,6 +32,7 @@ import { createFixtureLifetime } from "../helpers/fixture-lifetime.js"; import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js"; import { assertFixtureProcessGroupStopped } from "./exited-descendant-reaper.test-support.js"; import { createProcessGroupTimingPreload } from "./pr-operation-lock.test-support.js"; +import { createPrivateHandoffStoreFixture } from "./pr-private-handoff.test-support.js"; import { validClawsweeperReviewCommentPages, validReview, @@ -117,6 +118,15 @@ function createTemplateRepo() { writeFileSync(join(dir, ".git/info/exclude"), ".local/\n"); execFileSync("git", ["config", "user.name", "OpenClaw Test"], options); execFileSync("git", ["config", "user.email", "test@openclaw.invalid"], options); + // Copies retain these settings for later commands, not just template creation. + for (const [key, value] of [ + ["core.hooksPath", "/dev/null"], + ["commit.gpgSign", "false"], + ["gc.auto", "0"], + ["maintenance.auto", "false"], + ]) { + execFileSync("git", ["config", key!, value!], options); + } writeFileSync(join(dir, "base.txt"), "base\n"); execFileSync("git", ["add", "base.txt"], options); execFileSync("git", ["commit", "-qm", "base"], options); @@ -205,7 +215,11 @@ function bashSource(repoDir: string, supervised = false) { "set -euo pipefail", ...(supervised ? [] - : ["unset OPENCLAW_PR_LOCK_NOTIFY_FD", "unset OPENCLAW_PR_LOCK_SUPERVISOR_PID"]), + : [ + "unset OPENCLAW_PR_LOCK_NOTIFY_FD", + "unset OPENCLAW_PR_LOCK_SUPERVISOR_PID", + "unset OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT", + ]), `source '${worktreeScript}'`, `source '${lockScript}'`, `source '${commonScript}'`, @@ -213,6 +227,10 @@ function bashSource(repoDir: string, supervised = false) { ]; } +function shellQuote(value: string): string { + return `'${value.replace(/'/gu, `'\\''`)}'`; +} + function writeFixtureFile(repoDir: string, name: string, contents: string | readonly string[]) { const fixture = join(repoDir, name); writeFileSync(fixture, typeof contents === "string" ? contents : contents.join("\n")); @@ -343,6 +361,15 @@ async function runSupervisedFixture( fixture: string, options: SupervisedFixtureOptions = {}, ) { + // Entry bookkeeping is fixture-owned, not an untracked checkout transition input. + const entryDir = tempDirs.make("openclaw-pr-supervised-entry-"); + const groupFile = writeFixtureFile(entryDir, "supervised-fixture.pgid", ""); + const entry = writeFixtureFile(entryDir, "supervised-fixture-entry.sh", [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$$" > ${shellQuote(groupFile)}`, + `exec ${shellQuote(fixture)}`, + ]); + chmodSync(entry, 0o755); const controller = spawn( process.execPath, [ @@ -354,7 +381,7 @@ async function runSupervisedFixture( : []), processGroupRunner, repoDir, - fixture, + entry, ], { cwd: repoDir, @@ -381,28 +408,33 @@ async function runSupervisedFixture( controller.once("close", onClose); }); } catch (error) { + const failures: unknown[] = [error]; + // The runner forwards termination even when its child has not acquired a lock. + controller.kill("SIGTERM"); try { - if (refExists(repoDir)) { - const payload = execFileSync("git", ["cat-file", "blob", refOid(repoDir)], { - cwd: repoDir, - encoding: "utf8", - }); - const pgid = Number(/^version=3\nstate=active\npgid=([1-9][0-9]*)\n/u.exec(payload)?.[1]); - if (validProcessId(pgid)) { - await cleanupProcessGroup(pgid); - } + const pgid = readProcessIdFile(groupFile); + if (pgid) { + await cleanupProcessGroup(pgid); } - } catch { - // The controller still must die even if lock metadata is malformed. + } catch (cleanupError) { + failures.push(cleanupError); + } + try { + await waitForExit(controller, 2000); + } catch (cleanupError) { + failures.push(cleanupError); } finally { - controller.kill("SIGKILL"); + if (controller.exitCode === null && controller.signalCode === null) { + controller.kill("SIGKILL"); + } try { await waitForExit(controller, 2000); - } catch { - // Preserve the original bounded-exit failure below. + await cleanupRecordedProcessGroup(groupFile); + } catch (cleanupError) { + failures.push(cleanupError); } } - throw error; + throw new AggregateError(failures, "supervised fixture did not settle", { cause: error }); } return { status: controller.exitCode, signal: controller.signalCode, stdout, stderr }; } @@ -416,9 +448,14 @@ function runSupervisedOperation( return runSupervisedFixture(repoDir, writeOperationFixture(repoDir, name, commands), options); } -function runLockShell(repoDir: string, commands: string[]) { +function runLockShell( + repoDir: string, + commands: string[], + parentEnv: NodeJS.ProcessEnv = process.env, +) { return spawnSync("bash", ["-c", [...bashSource(repoDir), ...commands].join("\n")], { cwd: repoDir, + env: parentEnv, detached: true, encoding: "utf8", timeout: 10_000, @@ -771,6 +808,34 @@ describe("scripts/pr process-group platform guard", () => { const describePosix = process.platform === "win32" ? describe.skip : describe; describePosix("scripts/pr per-PR operation lock", () => { + it("isolates unsupervised candidate-source fixtures from unrelated supervisor bindings", () => { + const repoDir = createRepo(); + const result = runLockShell( + repoDir, + [ + 'test -z "${OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT-}"', + 'test -z "${OPENCLAW_PR_LOCK_NOTIFY_FD-}"', + 'test -z "${OPENCLAW_PR_LOCK_SUPERVISOR_PID-}"', + "acquire_pr_operation_lock 42", + 'git rev-parse --verify "' + lockRef + '"', + "release_pr_operation_lock", + ], + { + ...process.env, + OPENCLAW_PR_GITHUB_SNAPSHOT_ROOT: tempDirs.make("unrelated-lock-snapshot-"), + OPENCLAW_PR_LOCK_NOTIFY_FD: "3", + OPENCLAW_PR_LOCK_SUPERVISOR_PID: "1", + }, + ); + expect(result.status, result.stdout + result.stderr).toBe(0); + expect(result.stdout.trim()).toMatch(/^[0-9a-f]{40}$/); + const after = spawnSync("git", ["show-ref", "--verify", "--quiet", lockRef], { + cwd: repoDir, + encoding: "utf8", + }); + expect(after.status, after.stderr).toBe(1); + }); + it.each([ ["ls-files --others --exclude-standard -z", "require_no_foreign_untracked"], ["diff --name-only --no-renames -z", "require_no_ignored_transition_paths"], @@ -832,8 +897,10 @@ describePosix("scripts/pr per-PR operation lock", () => { const binDir = join(repoDir, "isolated-bin"); const cli = join(repoDir, "scripts/pr"); const realGit = realpathSync(join(binDir, "git")); + const handoff = createPrivateHandoffStoreFixture(homeDir, binDir); const env: NodeJS.ProcessEnv = { ...createPrFixtureEnv(homeDir, binDir), + ...handoff.env, TMPDIR: tmpDir, }; const git = (...args: string[]) => @@ -1013,6 +1080,13 @@ describePosix("scripts/pr per-PR operation lock", () => { controller.stderr!.on("data", (chunk) => (output += chunk)); try { await once(controller, "close", { signal: AbortSignal.timeout(20_000) }); + if ( + command === "review-init" && + !existing && + (failure === "healthy" || failure === "second") + ) { + handoff.assertProvisionersInjected(); + } const events = readFileSync(eventsPath, "utf8").trim().split("\n"); expect(git("ls-remote", "origin", "refs/pull/42/head"), output).toBe( `${pullHead}\trefs/pull/42/head`, diff --git a/test/scripts/pr-private-handoff.test-support.ts b/test/scripts/pr-private-handoff.test-support.ts new file mode 100644 index 000000000000..883011109569 --- /dev/null +++ b/test/scripts/pr-private-handoff.test-support.ts @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { delimiter, join } from "node:path"; +import { pathToFileURL } from "node:url"; + +/** Bind each cold provisioner process through the existing sealed-runtime DI seam. */ +export function createPrivateHandoffStoreFixture( + directory: string, + inheritedPath = process.env.PATH ?? "", +) { + const fixtureRoot = realpathSync(directory); + const root = join(fixtureRoot, ".local", "private-handoff"); + const storeRoot = join(root, "store"); + mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); + assert.equal(realpathSync(storeRoot), storeRoot); + const databasePath = join(storeRoot, "managed-update-handoffs.sqlite"); + const observations = join(root, "observations.jsonl"); + const preload = join(root, "preload.mjs"); + const launches = join(root, "launches.txt"); + const bin = join(root, "bin"); + const node = join(bin, "node"); + const preloadOption = `--import=${pathToFileURL(preload).href}`; + const shellQuote = (value: string) => `'${value.replace(/'/gu, `'\\''`)}'`; + mkdirSync(bin, { recursive: true }); + writeFileSync(launches, ""); + // The shell launch record is independent of NODE_OPTIONS. Refuse a missing + // binding before the source helper could access an uninjected store. + writeFileSync( + node, + [ + "#!/bin/sh", + "set -eu", + 'for arg in "$@"; do', + ' case "$arg" in', + " */scripts/pr-lib/worktree-provision.mts)", + ' printf "%s\\n" "$$" >> ' + shellQuote(launches), + ' case " ${NODE_OPTIONS-} " in *' + shellQuote(` ${preloadOption} `) + "*) ;;", + " *) echo 'Missing private handoff preload' >&2; exit 97 ;;", + " esac", + " break ;;", + " esac", + "done", + `exec ${shellQuote(realpathSync(process.execPath))} "$@"`, + "", + ].join("\n"), + ); + chmodSync(node, 0o755); + writeFileSync(observations, ""); + writeFileSync( + preload, + ` +import assert from "node:assert/strict"; +import { appendFileSync, realpathSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +// Git/GitHub stubs and the shell supervisor do not open handoff stores. +// The managed provisioner path can reach config write-lock admission. +// Native Git paths need no config write; verify injection in every helper anyway. +const entry = process.argv[1]; +if (entry?.endsWith("/scripts/pr-lib/worktree-provision.mts")) { + const sourceRoot = path.resolve(path.dirname(realpathSync(entry)), "../.."); + const storeRoot = ${JSON.stringify(storeRoot)}; + const databasePath = ${JSON.stringify(databasePath)}; + const observations = ${JSON.stringify(observations)}; + assert.equal(realpathSync(storeRoot), storeRoot); + const require = createRequire(path.join(sourceRoot, "package.json")); + const { resolveSecureTempRoot } = require("@openclaw/fs-safe/temp"); + const { registerSealedRuntime } = await import( + pathToFileURL(path.join(sourceRoot, "src/infra/sealed-runtime-registry.ts")).href + ); + let phase = "preflight"; + const observe = (kind) => appendFileSync(observations, JSON.stringify({ + kind, pid: process.pid, sourceRoot, databasePath, + }) + "\\n"); + registerSealedRuntime({ + json5: require("json5"), + resolveSecureTempRoot(options) { + const resolved = resolveSecureTempRoot({ ...options, preferredDir: storeRoot }); + assert.equal(resolved, storeRoot); + assert.equal(realpathSync(resolved), storeRoot); + observe(phase + "-resolved"); + return resolved; + }, + }); + await import(pathToFileURL(path.join(sourceRoot, "scripts/tsx.mjs")).href); + const { createManagedHandoffLeaseStore, resolveManagedUpdateLeaseDatabasePath } = await import( + pathToFileURL(path.join(sourceRoot, "src/infra/update-managed-service-handoff-lease.ts")).href + ); + assert.equal(resolveManagedUpdateLeaseDatabasePath(), databasePath); + observe("injected"); + // Verify injected production resolver/store before the helper executes. This + // is a preflight check, not evidence of later workload access, even when provisioning + // does not need a config write. Explicit options cannot select a live fallback. + const store = createManagedHandoffLeaseStore({ + databasePath: resolveManagedUpdateLeaseDatabasePath(), + serviceManagerEnv: process.env, + }); + store.assertSourceUnborrowed(path.join(storeRoot, "provisioner-preflight-config.json")); + observe("preflight-store-verified"); + phase = "runtime"; +} +`, + ); + return { + env: { + PATH: [bin, inheritedPath].join(delimiter), + NODE_OPTIONS: `--import=${pathToFileURL(preload).href}`, + }, + assertProvisionersInjected() { + const rows = readFileSync(observations, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + kind: string; + pid: number; + sourceRoot: string; + databasePath: string; + }, + ); + const injected = rows.filter((row) => row.kind === "injected"); + const launched = readFileSync(launches, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map(Number); + assert.ok(launched.length > 0, "cold provisioner never launched through the private owner"); + assert.ok(launched.every((pid) => Number.isSafeInteger(pid) && pid > 0)); + assert.equal(new Set(launched).size, launched.length, "ambiguous reused provisioner PID"); + assert.deepEqual( + injected.map((row) => row.pid).toSorted((a, b) => a - b), + launched.toSorted((a, b) => a - b), + "not every launched provisioner received its private store", + ); + assert.ok(injected.length > 0, "cold provisioner never received its private store"); + for (const row of rows) { + assert.equal(row.databasePath, databasePath); + } + for (const row of injected) { + assert.ok( + rows.some((other) => other.pid === row.pid && other.kind === "preflight-store-verified"), + "provisioner " + row.pid + " never verified its injected private store during preflight", + ); + assert.ok( + rows.some((other) => other.pid === row.pid && other.kind === "preflight-resolved"), + `provisioner ${row.pid} never preflight-verified its injected handoff store`, + ); + } + assert.equal(realpathSync(storeRoot), storeRoot); + }, + }; +} diff --git a/test/scripts/pr-worktree-provision.test.ts b/test/scripts/pr-worktree-provision.test.ts index 3d23fe185f38..29ee56e727e5 100644 --- a/test/scripts/pr-worktree-provision.test.ts +++ b/test/scripts/pr-worktree-provision.test.ts @@ -45,7 +45,14 @@ function coldFixture(perWorktreeConfig = true) { f.env.OPENCLAW_STATE_DIR = join(f.root, "state"); f.env.OPENCLAW_CONFIG_PATH = join(f.root, "config.json"); writeFileSync(f.env.OPENCLAW_CONFIG_PATH, "{}\n"); - return f; + return { + ...f, + run(...args: Parameters) { + const result = f.run(...args); + f.assertPrivateHandoffVerified(); + return result; + }, + }; } function expectSeed(f: ReturnType, pr = 42) { @@ -92,9 +99,11 @@ describePosix("native PR source provisioning", () => { JSON.stringify({ worktreeAcceleration: acceleration }), ); const preload = join(f.root, "native-provision-imports.mjs"); + const guardReceipt = join(f.root, "native-provision-imports.txt"); writeFileSync( preload, - `import { registerHooks } from "node:module"; + `import { appendFileSync } from "node:fs"; +import { registerHooks } from "node:module"; if (process.argv[1]?.endsWith("/worktree-provision.mts")) { registerHooks({ load(url, context, nextLoad) { if (url.endsWith("/src/config/config.ts")) { @@ -102,10 +111,12 @@ if (process.argv[1]?.endsWith("/worktree-provision.mts")) { } return nextLoad(url, context); } }); + appendFileSync(${JSON.stringify(guardReceipt)}, String(process.pid) + "\\n"); } `, ); - f.env.NODE_OPTIONS = `--import=${pathToFileURL(preload).href}`; + // Keep both guards: reject config startup and verify each private store. + f.env.NODE_OPTIONS = `--import=${pathToFileURL(preload).href} ${f.env.NODE_OPTIONS}`; const parent = join(f.canonical, ".worktrees"); const physicalParent = join(f.root, "pr-worktrees"); rmdirSync(parent); @@ -114,6 +125,7 @@ if (process.argv[1]?.endsWith("/worktree-provision.mts")) { const result = f.run("review-init"); expect(result.status, result.stderr).toBe(0); expect(result.stderr).toContain("PR source checkout: Git checkout."); + expect(readFileSync(guardReceipt, "utf8")).toMatch(/^[1-9]\d*\n$/); expectSeed(f); expect(f.git(f.worktree, "rev-parse", "--show-toplevel")).toBe(join(physicalParent, "pr-42")); expect(f.git(f.worktree, "rev-parse", "FETCH_HEAD")).toBe(f.main);