mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
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:
parent
7a4dfe8352
commit
8aefa9e43c
12 changed files with 789 additions and 292 deletions
24
openclaw.mjs
24
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,
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")], {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
|
|
|
|||
156
test/scripts/pr-private-handoff.test-support.ts
Normal file
156
test/scripts/pr-private-handoff.test-support.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue