fix: preserve the invoked launcher when restarting Node (#147621)

* fix(update): preserve replaced clone directories

* fix(update): verify linked Git runtime before rollback

* test(update): retain linked runtime fixture tuple types

* fix: preserve the invoked launcher when restarting Node

* test: preserve real cancellation coverage during main integration

* fix(config): import finite-number validator from value-tree

* fix: remove unused config mutation validator import

* test: join cleanup anchor retirement before teardown assertions

* test(node): join retirement close within runner lifetime

* test(node): make prepared workspace fixture owner private

* fix: distinguish imported CLI entries during cache respawn

* test(pr): preserve private-store binding in native provisioning fixtures

Compose the no-config preload with the private handoff preload, witness
its installation, and verify every cold helper including failure paths.
The exact two CI failures reproduce before and pass after this repair;
all native Git, hook, lock-loss, and APFS regressions remain.

Co-authored-by: Dallin Romney <6581799+RomneyDa@users.noreply.github.com>

---------

Co-authored-by: Jason (Json) <263060202+fuller-stack-dev@users.noreply.github.com>
Co-authored-by: Dallin Romney <6581799+RomneyDa@users.noreply.github.com>
This commit is contained in:
Dallin Romney 2026-09-22 09:00:04 -07:00 • committed by GitHub
parent 7a4dfe8352
commit 8aefa9e43c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 789 additions and 292 deletions

View file

@ -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,
);
};

View file

@ -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<NodeWorkerSupervisor["launch"]> | 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(() => {

View file

@ -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) {

View file

@ -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<string> {
const fixtureRoot = makeTempDir(fixtureRoots, "openclaw-launcher-");
async function makeLauncherFixture(
fixtures: ReturnType<typeof createFixtureLifetime>,
): Promise<string> {
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<typeof createFixtureLifetime>,
launcher: ReturnType<typeof spawn>,
): Promise<void> {
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")], {

View file

@ -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<typeof control>) {
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() {

View file

@ -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 });

View file

@ -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("");
});
});

View file

@ -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 <maintainer@example.com>\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");
}
}
});
});

View file

@ -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",

View file

@ -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`,

View file

@ -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);
},
};
}

View file

@ -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<typeof f.run>) {
const result = f.run(...args);
f.assertPrivateHandoffVerified();
return result;
},
};
}
function expectSeed(f: ReturnType<typeof coldFixture>, 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);