fix: keep source-only sessions free of dependency installs (#149587)

* fix: keep source-only sessions free of dependency installs

* fix: satisfy setup typing and ordering checks

* test(plugins): cover bundled but publishable packages
This commit is contained in:
Jason (Json) 2026-09-16 15:47:50 -06:00 • committed by GitHub
parent 20230c35f0
commit f5c3d16561
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 966 additions and 13 deletions

View file

@ -6,8 +6,8 @@
"hooks": [
{
"type": "command",
"command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; command -v pnpm >/dev/null 2>&1 && [ -f pnpm-lock.yaml ] || exit 0; CI=true pnpm install --frozen-lockfile --ignore-scripts || echo \"[worktree-setup] pnpm install failed; run pnpm install manually in this worktree\"'",
"timeout": 600
"command": "bash -c 'r=\"$(git rev-parse --show-toplevel 2>/dev/null)\"; [ -n \"$r\" ] && cd \"$r\" || exit 0; node scripts/worktree-setup.mjs source'",
"timeout": 10
}
]
}

View file

@ -5,9 +5,9 @@ import fs from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
// Discovers and runs bundled plugin package asset hooks.
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { runManagedCommand } from "./lib/managed-child-process.mts";
import { assertRealOutputRoot } from "./lib/output-root-guard.mjs";
import { isRecord } from "./lib/record-shared.mjs";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import { listGeneratedExtensionAssetSources } from "./lib/static-extension-assets.mts";
const rootDir = resolveRepoRoot(import.meta.url);

288
scripts/worktree-setup.mjs Normal file
View file

@ -0,0 +1,288 @@
#!/usr/bin/env node
// Explicit repository preparation; source composition never selects a workload.
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const WORKLOADS = new Set(["source", "gateway", "full"]);
const SOURCE_GUIDANCE =
"[worktree-setup] Source-only: no dependencies installed or artifacts built. " +
"For gateway work run node scripts/worktree-setup.mjs gateway; " +
"for full preparation run node scripts/worktree-setup.mjs full.";
const FULL_TRANSITION = "Run git sparse-checkout disable, then retry preparation.";
function git(rootDir, args, input) {
return execFileSync("git", args, {
cwd: rootDir,
encoding: "utf8",
input,
maxBuffer: 32 * 1024 * 1024,
stdio: ["pipe", "pipe", "pipe"],
});
}
function readSparseState(rootDir) {
const result = spawnSync("git", ["config", "--get", "--bool", "core.sparseCheckout"], {
cwd: rootDir,
encoding: "utf8",
});
if (result.error) {
throw result.error;
}
if (result.status === 1) {
return false;
}
if (result.status !== 0) {
throw new Error(result.stderr.trim() || "Cannot read Git sparse-checkout state.");
}
return result.stdout.trim() === "true";
}
function validateInputs(rootDir, workload) {
const topLevel = git(rootDir, ["rev-parse", "--show-toplevel"]).trim();
if (fs.realpathSync(topLevel) !== fs.realpathSync(rootDir)) {
throw new Error("Run preparation from the target repository root.");
}
const sparse = readSparseState(rootDir);
if (workload === "full" && sparse) {
throw new Error("Full preparation requires full source. " + FULL_TRANSITION);
}
const tracked = git(rootDir, ["ls-files", "-z"]).split("\0").filter(Boolean);
let required = tracked;
if (sparse) {
// Git interprets the repository-owned cone list. This helper has no profile
// parser and never changes checkout state.
const definition = path.join(rootDir, ".openclaw/worktree-profiles/gateway");
if (!fs.existsSync(definition)) {
throw new Error(
"Sparse gateway preparation needs the repository gateway definition. " + FULL_TRANSITION,
);
}
required = git(
rootDir,
["sparse-checkout", "check-rules", "--cone", "--rules-file", definition, "-z"],
tracked.join("\0") + "\0",
)
.split("\0")
.filter(Boolean);
const included = new Set(
git(rootDir, ["sparse-checkout", "check-rules", "-z"], required.join("\0") + "\0")
.split("\0")
.filter(Boolean),
);
const excluded = required.filter((file) => !included.has(file));
if (excluded.length > 0) {
throw new Error(
"Gateway inputs are excluded: " + excluded.slice(0, 10).join(", ") + ". " + FULL_TRANSITION,
);
}
}
const missing = required.filter((file) => !fs.existsSync(path.join(rootDir, file)));
if (missing.length > 0) {
throw new Error(
"Tracked preparation inputs are missing: " +
missing.slice(0, 10).join(", ") +
". " +
FULL_TRANSITION,
);
}
// The package/build inventories below use the filesystem. Validate first so
// a missing tracked plugin cannot silently narrow the selected install.
for (const file of [
"package.json",
"pnpm-workspace.yaml",
"pnpm-lock.yaml",
"scripts/build-all.mts",
]) {
if (!fs.existsSync(path.join(rootDir, file))) {
throw new Error("Missing preparation input: " + file);
}
}
return { sparse, tracked, requiredCount: required.length };
}
export async function createWorktreeSetupPlan({ rootDir, workload = "source", env = process.env }) {
if (!WORKLOADS.has(workload)) {
throw new Error("Unknown preparation workload: " + workload);
}
if (workload === "source") {
return { workload, packageManager: null, install: null, build: null, requiredInputs: null };
}
const inputs = validateInputs(rootDir, workload);
const pkg = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
if (
typeof pkg.packageManager !== "string" ||
!/^pnpm@\d+\.\d+\.\d+(?:[-+].*)?$/u.test(pkg.packageManager)
) {
throw new Error("Target package.json must pin pnpm through packageManager.");
}
const filters = new Set();
if (workload === "gateway") {
const { collectSourceCheckoutPluginBuildEntries } =
await import("./lib/bundled-plugin-build-entries.mjs");
const { readBundledPluginAssetHooks } = await import("./bundled-plugin-assets.mts");
if (typeof pkg.name !== "string" || !pkg.name) {
throw new Error("Target package.json is missing its package name.");
}
filters.add(pkg.name + "...");
filters.add("./packages/*...");
for (const entry of collectSourceCheckoutPluginBuildEntries({ cwd: rootDir, env })) {
if (entry.hasPackageJson) {
filters.add("./extensions/" + entry.id + "...");
}
}
for (const phase of ["build", "copy"]) {
for (const hook of await readBundledPluginAssetHooks({ rootDir, phase })) {
filters.add(
"./" + path.relative(rootDir, hook.pluginDir).split(path.sep).join("/") + "...",
);
}
}
}
const filterArgs = [...filters]
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap((filter) => ["--filter", filter]);
return {
workload,
packageManager: pkg.packageManager,
install: { command: "pnpm", args: [...filterArgs, "install", "--frozen-lockfile"] },
build:
workload === "gateway"
? {
command: "node",
args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
}
: { command: "pnpm", args: ["build"] },
requiredInputs: { sparse: inputs.sparse, trackedFiles: inputs.requiredCount },
};
}
function existingAncestor(target) {
let current = target;
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) {
throw new Error("Cannot inspect package store volume: " + target);
}
current = parent;
}
return current;
}
async function executePreparation(rootDir, workload) {
const plan = await createWorktreeSetupPlan({ rootDir, workload });
const { assertRealOutputRoot } = await import("./lib/output-root-guard.mjs");
const { createPnpmRunnerSpawnSpec } = await import("./pnpm-runner.mts");
const { runManagedCommand } = await import("./lib/managed-child-process.mts");
const validateOutputRoots = () => {
const manifests = git(rootDir, ["ls-files", "-z"])
.split("\0")
.filter((file) => file === "package.json" || file.endsWith("/package.json"));
for (const manifest of manifests) {
const packageRoot = path.join(rootDir, path.dirname(manifest));
assertRealOutputRoot(path.join(packageRoot, "node_modules"));
}
assertRealOutputRoot(path.join(rootDir, "node_modules/.pnpm"));
assertRealOutputRoot(path.join(rootDir, "dist"));
};
validateOutputRoots();
const pnpm = (args) => createPnpmRunnerSpawnSpec({ cwd: rootDir, pnpmArgs: args });
const capture = (args) => {
const spec = pnpm(args);
return execFileSync(spec.command, spec.args, {
...spec.options,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
};
const version = capture(["--version"]);
const pinnedVersion = plan.packageManager.slice("pnpm@".length).split("+")[0];
if (version !== pinnedVersion) {
throw new Error(
"Expected repository pnpm " +
pinnedVersion +
", got " +
version +
". Run through the repository-pinned toolchain.",
);
}
const storePath = capture(["store", "path"]);
if (
!path.isAbsolute(storePath) ||
fs.statSync(existingAncestor(storePath)).dev !== fs.statSync(rootDir).dev
) {
throw new Error("Preparation requires a pnpm store on the same volume as the worktree.");
}
// Do not trust a previously displayed plan as permission to prepare changed inputs.
const current = await createWorktreeSetupPlan({ rootDir, workload });
if (JSON.stringify(current) !== JSON.stringify(plan)) {
throw new Error("Preparation inputs changed while checking the toolchain; retry.");
}
// Toolchain probes can take time; reject output roots linked since the first check.
validateOutputRoots();
for (const step of [current.install, current.build]) {
const spec =
step.command === "pnpm"
? pnpm(step.args)
: {
command: process.execPath,
args: step.args,
options: { cwd: rootDir, shell: false, stdio: "inherit" },
};
const status = await runManagedCommand({
bin: spec.command,
args: spec.args,
...spec.options,
requireProcessTreeExit: process.platform !== "win32",
});
if (status !== 0) {
return status;
}
}
return 0;
}
export function parseWorktreeSetupArgs(argv) {
let workload;
let plan = false;
let help = false;
for (const arg of argv) {
if (arg === "--plan") {
plan = true;
} else if (arg === "--help" || arg === "-h") {
help = true;
} else if (WORKLOADS.has(arg) && workload === undefined) {
workload = arg;
} else {
throw new Error("Unexpected preparation argument: " + arg);
}
}
return { workload: workload ?? "source", plan, help };
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
try {
const args = parseWorktreeSetupArgs(process.argv.slice(2));
if (args.help) {
console.log(
"Usage: node scripts/worktree-setup.mjs [source|gateway|full] [--plan]\n" + SOURCE_GUIDANCE,
);
} else if (args.plan) {
console.log(
JSON.stringify(
await createWorktreeSetupPlan({ rootDir: process.cwd(), workload: args.workload }),
null,
2,
),
);
} else if (args.workload === "source") {
console.log(SOURCE_GUIDANCE);
} else {
process.exitCode = await executePreparation(process.cwd(), args.workload);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
}
}

View file

@ -407,19 +407,18 @@ describe("official external plugin catalog", () => {
expect(gaps).toEqual([]);
});
it("declares each published ClawHub counterpart in its package and discovery catalog", () => {
it("declares each ClawHub publication target in its package", () => {
const gaps = listPublishedPluginOwners().flatMap(
({ id, packageName, install, publishToClawHub }) => {
if (!publishToClawHub) {
return [];
}
const expected = `clawhub:${packageName}`;
const catalogSpec = resolveOfficialExternalPluginInstall(
expectCatalogEntry(id),
)?.clawhubSpec;
return install.clawhubSpec === expected && catalogSpec === expected
// Bundled packages may be publishable before external catalog publication.
// The preceding test checks catalog/install parity for external owners.
return install.clawhubSpec === expected
? []
: [{ id, packageName, expected, packageSpec: install.clawhubSpec, catalogSpec }];
: [{ id, packageName, expected, packageSpec: install.clawhubSpec }];
},
);
expect(gaps).toEqual([]);

View file

@ -685,7 +685,14 @@ describe("collectPublishablePluginPackages", () => {
});
it("keeps publishable plugin dist trees out of the core npm package unless bundled", () => {
const corePackageRuntimePluginIds = new Set(["discord"]);
// These publication targets intentionally remain in the core distribution.
// bundledDist: true would defer their separate npm/ClawHub publication.
const corePackageRuntimePluginIds = new Set([
"discord",
"logbook",
"memory-wiki",
"onepassword",
]);
const rootPackage = JSON.parse(readFileSync("package.json", "utf8")) as {
files?: unknown;
};

View file

@ -1772,7 +1772,7 @@ mutateModule.syncBuiltinESMExports();
expect(existsSync(sentinel)).toBe(false);
});
it("matches the exact current publisher inventory: 95 npm and 91 ClawHub packages", () => {
it("matches the exact current publisher inventory: 98 npm and 94 ClawHub packages", () => {
const root = tempDirs.make("openclaw-release-plan-current-");
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
cwd: resolve("."),
@ -1825,8 +1825,8 @@ mutateModule.syncBuiltinESMExports();
const clawHubPackages = plan.inventory.packages.filter((entry) =>
entry.targets.includes("clawhub"),
);
expect(npmPackages).toHaveLength(95);
expect(clawHubPackages).toHaveLength(91);
expect(npmPackages).toHaveLength(98);
expect(clawHubPackages).toHaveLength(94);
const coreNpmPackages = new Set([
"@openclaw/ai",
"@openclaw/gateway-client",

View file

@ -0,0 +1,659 @@
import assert from "node:assert/strict";
import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import { readBundledPluginAssetHooks } from "../../scripts/bundled-plugin-assets.mts";
import { collectSourceCheckoutPluginBuildEntries } from "../../scripts/lib/bundled-plugin-build-entries.mjs";
import { createWorktreeSetupPlan, parseWorktreeSetupArgs } from "../../scripts/worktree-setup.mjs";
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const repoRoot = fileURLToPath(new URL("../../", import.meta.url));
const scriptPath = path.join(repoRoot, "scripts/worktree-setup.mjs");
// Copy real script owners, not replacement inventories or dependency symlinks.
// This bounded list deliberately fails if the plain-Node import closure grows.
const COLD_SCRIPT_INPUTS = [
"scripts/worktree-setup.mjs",
"scripts/bundled-plugin-assets.mts",
"scripts/pnpm-runner.mts",
"scripts/windows-cmd-helpers.mjs",
"scripts/lib/bundled-plugin-build-entries.mjs",
"scripts/lib/bundled-plugin-paths.mjs",
"scripts/lib/managed-child-process.mts",
"scripts/lib/optional-bundled-clusters.mjs",
"scripts/lib/output-root-guard.mjs",
"scripts/lib/record-shared.mjs",
"scripts/lib/repo-root.mjs",
"scripts/lib/root-package-bundled-plugin-excludes.mjs",
"scripts/lib/static-extension-assets.mts",
"scripts/lib/vitest-resource-ownership.mts",
"scripts/lib/windows-taskkill.mjs",
"src/shared/non-packaged-plugin-dirs.ts",
];
type CommandCall = { kind: "pnpm" | "gateway-build"; args: string[]; cwd: string };
function makePreparationFixture() {
const directory = tempDirs.make("openclaw-preparation-");
const rootDir = path.join(directory, "repo");
const controls = path.join(directory, "controls");
fs.mkdirSync(rootDir);
fs.mkdirSync(controls);
const write = (relative: string, value: string) => {
const target = path.join(rootDir, relative);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, value);
};
const json = (relative: string, value: unknown) => write(relative, JSON.stringify(value));
const env: NodeJS.ProcessEnv = { ...process.env, GIT_NO_LAZY_FETCH: "1" };
// Cold execution must not inherit a loader or package path from the test runner.
for (const key of [
"NODE_OPTIONS",
"NODE_PATH",
"npm_execpath",
"OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS",
"OPENCLAW_INTERNAL_DOCKER_BUILD_PLUGIN_IDS",
"OPENCLAW_INCLUDE_OPTIONAL_BUNDLED",
"OPENCLAW_BUILD_PRIVATE_QA",
"GIT_DIR",
"GIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_COMMON_DIR",
]) {
delete env[key];
}
const git = (...args: string[]) =>
execFileSync("git", args, {
cwd: rootDir,
env,
encoding: "utf8",
});
for (const input of COLD_SCRIPT_INPUTS) {
write(input, fs.readFileSync(path.join(repoRoot, input), "utf8"));
}
const pin: string = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"),
).packageManager;
json("package.json", { name: "openclaw", type: "module", packageManager: pin });
write("pnpm-workspace.yaml", "packages:\n - .\n - packages/*\n - extensions/*\n");
write("pnpm-lock.yaml", "lockfileVersion: '9.0'\n");
json("packages/support/package.json", { name: "@openclaw/support", version: "0.0.0" });
json("extensions/isolated/package.json", {
name: "@openclaw/isolated",
dependencies: { "@openclaw/support": "workspace:*" },
openclaw: {
extensions: ["./index.ts"],
build: { bundledDist: false },
release: { publishToNpm: true },
},
});
json("extensions/isolated/openclaw.plugin.json", { id: "isolated" });
write("extensions/isolated/index.ts", "export {};\n");
// No manifest or entrypoint: these owners exist only in the asset inventory.
for (const phase of ["build", "copy"]) {
json("extensions/asset-" + phase + "/package.json", {
name: "@example/asset-" + phase,
openclaw: { assetScripts: { [phase]: "node asset.mjs" } },
});
}
write(".openclaw/worktree-profiles/gateway", "scripts\npackages\nextensions\nsrc\n");
write("src/gateway/input.ts", "export {};\n");
write("apps/android/build.gradle.kts", "// Omitted by the synthetic gateway cone.\n");
// These two files are command recorders, NOT a gateway build implementation.
write("scripts/tsx.mjs", "export {};\n");
write(
"scripts/build-all.mts",
[
'import fs from "node:fs";',
"fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({",
' kind: "gateway-build", args: process.argv.slice(2), cwd: process.cwd(),',
'}) + "\\n");',
'process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");',
].join("\n"),
);
git("init", "--quiet");
git("add", ".");
git(
"-c",
"user.name=Setup Test",
"-c",
"user.email=setup@example.invalid",
"-c",
"commit.gpgsign=false",
"-c",
"core.hooksPath=" + controls,
"commit",
"--quiet",
"-m",
"fixture",
);
const callsPath = path.join(controls, "calls.jsonl");
const pnpmPath = path.join(controls, "pnpm.cjs");
const recorder = [
'const fs = require("node:fs");',
"const args = process.argv.slice(2);",
"fs.appendFileSync(process.env.SETUP_TEST_CALLS, JSON.stringify({",
' kind: "pnpm", args, cwd: process.cwd(),',
'}) + "\\n");',
'if (args.length === 1 && args[0] === "--version") {',
" console.log(process.env.SETUP_TEST_VERSION);",
'} else if (args.join(" ") === "store path") {',
" if (process.env.SETUP_TEST_STORE_HOOK) { require(process.env.SETUP_TEST_STORE_HOOK); }",
" console.log(process.env.SETUP_TEST_STORE);",
'} else if (args.includes("install")) {',
' process.exitCode = Number(process.env.SETUP_TEST_INSTALL_EXIT ?? "0");',
'} else if (args.join(" ") === "build") {',
' process.exitCode = Number(process.env.SETUP_TEST_BUILD_EXIT ?? "0");',
"} else {",
" process.exitCode = 97;",
"}",
].join("\n");
fs.writeFileSync(pnpmPath, recorder);
// Trap fallback resolution as well; no test may invoke the host's real pnpm.
fs.writeFileSync(path.join(controls, "pnpm"), "#!/usr/bin/env node\n" + recorder, {
mode: 0o755,
});
fs.writeFileSync(
path.join(controls, "pnpm.cmd"),
'@"' + process.execPath + '" "' + pnpmPath + '" %*\r\n',
);
Object.assign(env, {
npm_execpath: pnpmPath,
PATH:
controls +
path.delimiter +
path.dirname(process.execPath) +
path.delimiter +
(env.PATH ?? ""),
SETUP_TEST_CALLS: callsPath,
SETUP_TEST_VERSION: pin.slice("pnpm@".length).split("+")[0],
SETUP_TEST_STORE: path.join(controls, "store", "not-created"),
});
const run = (args: string[], extraEnv: NodeJS.ProcessEnv = {}, nodeArgs: string[] = []) => {
const result = spawnSync(
process.execPath,
[...nodeArgs, path.join(rootDir, "scripts/worktree-setup.mjs"), ...args],
{ cwd: rootDir, env: { ...env, ...extraEnv }, encoding: "utf8" },
);
expect(result.error).toBeUndefined();
return result;
};
const calls = (): CommandCall[] =>
fs.existsSync(callsPath)
? fs
.readFileSync(callsPath, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line))
: [];
const assertNoOutputs = () => {
for (const relative of [
"node_modules",
"dist",
"packages/support/node_modules",
"extensions/isolated/node_modules",
"apps/android/build",
]) {
expect(fs.existsSync(path.join(rootDir, relative)), relative).toBe(false);
}
};
return { rootDir, controls, env, run, git, write, calls, assertNoOutputs };
}
describe("source-only worktree preparation", () => {
it("runs source plans without Git, pnpm, repository imports, or generated output", () => {
const rootDir = tempDirs.make("openclaw-source-preparation-");
const script = path.join(rootDir, "worktree-setup.mjs");
fs.copyFileSync(scriptPath, script);
for (const args of [[], ["source"], ["--plan"]]) {
const output = execFileSync(process.execPath, [script, ...args], {
cwd: rootDir,
env: { ...process.env, PATH: "", NODE_OPTIONS: "", NODE_PATH: "" },
encoding: "utf8",
});
if (args.includes("--plan")) {
expect(JSON.parse(output)).toMatchObject({
workload: "source",
install: null,
build: null,
});
} else {
expect(output).toContain("Source-only");
}
expect(fs.readdirSync(rootDir)).toEqual(["worktree-setup.mjs"]);
}
});
it("rejects invalid selectors before reading repository inputs", async () => {
expect(() => parseWorktreeSetupArgs(["gateway", "full"])).toThrow("Unexpected");
expect(() => parseWorktreeSetupArgs(["--profile", "gateway"])).toThrow("Unexpected");
expect(() => parseWorktreeSetupArgs(["--source-profile", "gateway"])).toThrow("Unexpected");
await expect(
createWorktreeSetupPlan({ rootDir: "/missing-worktree", workload: "unknown" }),
).rejects.toThrow("Unknown preparation workload");
});
it.skipIf(process.platform === "win32")(
"keeps the actual Claude startup hook source-only",
() => {
const fixture = makePreparationFixture();
const settings = JSON.parse(
fs.readFileSync(path.join(repoRoot, ".claude/settings.json"), "utf8"),
) as {
hooks: { SessionStart: { matcher: string; hooks: { type: string; command: string }[] }[] };
};
const hooks = settings.hooks.SessionStart.filter((entry) => entry.matcher === "startup")
.flatMap((entry) => entry.hooks)
.filter((hook) => hook.type === "command");
expect(hooks.length).toBeGreaterThan(0);
// Source startup must work even when the cone cannot prepare the gateway.
// Keep only scripts: the missing tracked plugin would fail input validation
// if startup accidentally inferred gateway preparation from sparse state.
for (const state of ["full", "sparse"]) {
if (state === "sparse") {
fixture.git("sparse-checkout", "set", "--cone", "scripts");
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe(
false,
);
}
const before = fixture.git("status", "--porcelain", "--untracked-files=all");
for (const hook of hooks) {
const output = execFileSync("/bin/sh", ["-c", hook.command], {
cwd: path.join(fixture.rootDir, "scripts"),
env: fixture.env,
encoding: "utf8",
});
expect(output).toContain("Source-only");
}
expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before);
if (state === "sparse") {
expect(fixture.git("sparse-checkout", "list").trim()).toBe("scripts");
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
}
},
);
});
describe("explicit preparation input closure", () => {
it("plans the actual checkout from canonical source and both asset inventories", async () => {
const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, "package.json"), "utf8"));
const entries = collectSourceCheckoutPluginBuildEntries({ cwd: repoRoot, env: {} });
const buildHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "build" });
const copyHooks = await readBundledPluginAssetHooks({ rootDir: repoRoot, phase: "copy" });
expect(entries.length).toBeGreaterThan(0);
expect(buildHooks.length).toBeGreaterThan(0);
expect(copyHooks.length).toBeGreaterThan(0);
const expected = new Set([pkg.name + "...", "./packages/*..."]);
for (const entry of entries) {
if (entry.hasPackageJson) {
expected.add("./extensions/" + entry.id + "...");
}
}
for (const hook of [...buildHooks, ...copyHooks]) {
expected.add(
"./" + path.relative(repoRoot, hook.pluginDir).split(path.sep).join("/") + "...",
);
}
const plan = await createWorktreeSetupPlan({ rootDir: repoRoot, workload: "gateway", env: {} });
expect(plan.install).toEqual({
command: "pnpm",
args: [...expected]
.toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0))
.flatMap((filter) => ["--filter", filter])
.concat(["install", "--frozen-lockfile"]),
});
expect(plan.build).toEqual({
command: "node",
args: ["--import", "./scripts/tsx.mjs", "scripts/build-all.mts", "qaRuntime"],
});
});
it("runs cold plain-Node gateway and full plans without resolving pnpm or producing outputs", () => {
const fixture = makePreparationFixture();
const before = fixture.git("status", "--porcelain", "--untracked-files=all");
const gateway = fixture.run(["gateway", "--plan"], { SETUP_TEST_VERSION: "wrong" });
expect(gateway.status, gateway.stderr).toBe(0);
expect(JSON.parse(gateway.stdout).install).toEqual({
command: "pnpm",
args: [
"--filter",
"./extensions/asset-build...",
"--filter",
"./extensions/asset-copy...",
"--filter",
"./extensions/isolated...",
"--filter",
"./packages/*...",
"--filter",
"openclaw...",
"install",
"--frozen-lockfile",
],
});
const full = fixture.run(["full", "--plan"], { SETUP_TEST_VERSION: "wrong" });
expect(full.status, full.stderr).toBe(0);
expect(JSON.parse(full.stdout)).toMatchObject({
install: { command: "pnpm", args: ["install", "--frozen-lockfile"] },
build: { command: "pnpm", args: ["build"] },
});
expect(fixture.calls()).toEqual([]);
expect(fixture.git("status", "--porcelain", "--untracked-files=all")).toBe(before);
fixture.assertNoOutputs();
});
it("accepts complete gateway cones, rejects sparse full, and preserves explicit native expansion", () => {
const fixture = makePreparationFixture();
fixture.git(
"sparse-checkout",
"set",
"--cone",
"scripts",
"packages",
"extensions",
"src",
".openclaw/worktree-profiles",
);
expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(false);
const gateway = fixture.run(["gateway", "--plan"]);
expect(gateway.status, gateway.stderr).toBe(0);
expect(JSON.parse(gateway.stdout).requiredInputs.sparse).toBe(true);
const patternsBefore = fixture.git("sparse-checkout", "list");
for (const args of [["full"], ["full", "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("git sparse-checkout disable");
}
expect(fixture.git("sparse-checkout", "list")).toBe(patternsBefore);
expect(fixture.run(["source"]).status).toBe(0);
expect(fixture.calls()).toEqual([]);
fixture.git("sparse-checkout", "disable");
expect(fs.existsSync(path.join(fixture.rootDir, "apps/android/build.gradle.kts"))).toBe(true);
const full = fixture.run(["full", "--plan"]);
expect(full.status, full.stderr).toBe(0);
expect(JSON.parse(full.stdout)).toMatchObject({
install: { command: "pnpm", args: ["install", "--frozen-lockfile"] },
build: { command: "pnpm", args: ["build"] },
requiredInputs: { sparse: false },
});
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it("rejects a gateway selection changed during toolchain checks before install", () => {
const fixture = makePreparationFixture();
const hook = path.join(fixture.controls, "change-owner.cjs");
fs.writeFileSync(
hook,
[
'const fs = require("node:fs");',
'const file = "extensions/asset-copy/package.json";',
'const pkg = JSON.parse(fs.readFileSync(file, "utf8"));',
"delete pkg.openclaw.assetScripts.copy;",
"fs.writeFileSync(file, JSON.stringify(pkg));",
].join("\n"),
);
const result = fixture.run(["gateway"], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Preparation inputs changed");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
const pkg = JSON.parse(
fs.readFileSync(path.join(fixture.rootDir, "extensions/asset-copy/package.json"), "utf8"),
);
expect(pkg.openclaw.assetScripts.copy).toBeUndefined();
fixture.assertNoOutputs();
});
it("rejects excluded tracked asset owners before pnpm, even when collectors would omit them", () => {
const fixture = makePreparationFixture();
fixture.git(
"sparse-checkout",
"set",
"--cone",
"scripts",
"packages",
"extensions/isolated",
"src",
".openclaw/worktree-profiles",
);
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/asset-build/package.json"))).toBe(
false,
);
for (const args of [["gateway"], ["gateway", "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Gateway inputs are excluded");
expect(result.stderr).toContain("extensions/asset-build/package.json");
expect(result.stderr).toContain("git sparse-checkout disable");
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it("resolves linked plugin inputs before inventory discovery", () => {
const fixture = makePreparationFixture();
const manifest = path.join(fixture.rootDir, "extensions/isolated/package.json");
const target = path.join(fixture.controls, "linked-package.json");
fs.renameSync(manifest, target);
fs.symlinkSync(target, manifest, "file");
const valid = fixture.run(["gateway", "--plan"]);
expect(valid.status).toBe(0);
expect(JSON.parse(valid.stdout).install.args).toContain("./extensions/isolated...");
expect(fixture.calls()).toEqual([]);
fs.unlinkSync(target);
for (const args of [["gateway", "--plan"], ["gateway"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/package.json");
expect(fs.readlinkSync(manifest)).toBe(target);
expect(fs.existsSync(target)).toBe(false);
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
});
it.each([
{ workload: "gateway", missing: "extensions/isolated/index.ts" },
{ workload: "gateway", missing: "extensions/isolated" },
{ workload: "full", missing: "extensions/isolated/index.ts" },
{ workload: "full", missing: "extensions/isolated" },
])(
"rejects missing tracked $missing before inventory discovery or pnpm for $workload",
({ workload, missing }) => {
const fixture = makePreparationFixture();
// This deletes only a future test-owned fixture input, never retained source.
fs.rmSync(path.join(fixture.rootDir, missing), { recursive: true });
for (const args of [[workload], [workload, "--plan"]]) {
const result = fixture.run(args);
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/index.ts");
if (missing === "extensions/isolated") {
expect(result.stderr).toContain("extensions/isolated/package.json");
expect(result.stderr).toContain("extensions/isolated/openclaw.plugin.json");
}
}
expect(fixture.calls()).toEqual([]);
fixture.assertNoOutputs();
},
);
});
// These are subprocess/ordering regressions. Recorders never install packages,
// prove pnpm closure, build the Gateway, or establish graph/volume isolation.
describe.each(["gateway", "full"])("%s preparation execution boundaries", (workload) => {
it("rejects the wrong target pnpm before querying its store or installing", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_VERSION: "0.0.0" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Expected repository pnpm");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"]]);
fixture.assertNoOutputs();
});
it("rejects a nonabsolute store before install", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_STORE: "relative/store" });
expect(result.status).toBe(1);
expect(result.stderr).toContain("same volume");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it("rejects a simulated foreign-volume store before install", () => {
const fixture = makePreparationFixture();
const store = path.join(fixture.controls, "foreign-store");
fs.mkdirSync(store);
const preload = path.join(fixture.controls, "foreign-device.mjs");
// Simulate only st_dev for this sentinel path; no mount or shared fs mutation.
fs.writeFileSync(
preload,
[
'import fs from "node:fs";',
"const original = fs.statSync;",
"fs.statSync = function (target, ...args) {",
" const stat = original.call(this, target, ...args);",
" if (target === " + JSON.stringify(store) + ") { stat.dev += 1; }",
" return stat;",
"};",
].join("\n"),
);
const result = fixture.run([workload], { SETUP_TEST_STORE: store }, [
"--import",
pathToFileURL(preload).href,
]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("same volume");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it.each(["node_modules", "node_modules/.pnpm", "packages/support/node_modules", "dist"])(
"rejects linked %s before invoking pnpm and preserves its target",
(relative) => {
const fixture = makePreparationFixture();
const target = path.join(fixture.controls, "retained");
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, "sentinel"), "unchanged");
const link = path.join(fixture.rootDir, relative);
fs.mkdirSync(path.dirname(link), { recursive: true });
fs.symlinkSync(target, link, process.platform === "win32" ? "junction" : "dir");
const result = fixture.run([workload]);
expect(result.status).toBe(1);
expect(result.stderr).toContain("symbolic link");
expect(fixture.calls()).toEqual([]);
expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged");
expect(fs.readdirSync(target)).toEqual(["sentinel"]);
expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
},
);
it("revalidates missing inputs after store resolution and before install", () => {
const fixture = makePreparationFixture();
const hook = path.join(fixture.controls, "remove-input.cjs");
fs.writeFileSync(hook, 'require("node:fs").unlinkSync("extensions/isolated/index.ts");\n');
const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("Tracked preparation inputs are missing");
expect(result.stderr).toContain("extensions/isolated/index.ts");
expect(fs.existsSync(path.join(fixture.rootDir, "extensions/isolated/index.ts"))).toBe(false);
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
fixture.assertNoOutputs();
});
it("revalidates output roots after store resolution and before install", () => {
const fixture = makePreparationFixture();
const target = path.join(fixture.controls, "retained");
fs.mkdirSync(target);
fs.writeFileSync(path.join(target, "sentinel"), "unchanged");
const hook = path.join(fixture.controls, "link-output.cjs");
fs.writeFileSync(
hook,
[
'const fs = require("node:fs");',
"fs.symlinkSync(" +
JSON.stringify(target) +
', "dist", ' +
JSON.stringify(process.platform === "win32" ? "junction" : "dir") +
");",
].join("\n"),
);
const result = fixture.run([workload], { SETUP_TEST_STORE_HOOK: hook });
expect(result.status).toBe(1);
expect(result.stderr).toContain("symbolic link");
expect(fixture.calls().map((call) => call.args)).toEqual([["--version"], ["store", "path"]]);
expect(fs.lstatSync(path.join(fixture.rootDir, "dist")).isSymbolicLink()).toBe(true);
expect(fs.readdirSync(target)).toEqual(["sentinel"]);
expect(fs.readFileSync(path.join(target, "sentinel"), "utf8")).toBe("unchanged");
expect(fs.existsSync(path.join(fixture.rootDir, "node_modules"))).toBe(false);
});
it("propagates install failure without starting a build", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_INSTALL_EXIT: "23" });
expect(result.status, result.stderr).toBe(23);
const calls = fixture.calls();
expect(calls).toHaveLength(3);
expect(calls.map((call) => call.kind)).toEqual(["pnpm", "pnpm", "pnpm"]);
expect(calls.at(-1)?.args).toContain("install");
expect(calls.at(-1)?.args).toContain("--frozen-lockfile");
fixture.assertNoOutputs();
});
it("propagates selected-build failure after a successful recorder install", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload], { SETUP_TEST_BUILD_EXIT: "29" });
expect(result.status, result.stderr).toBe(29);
const calls = fixture.calls();
expect(calls).toHaveLength(4);
assert.ok(calls[2]);
expect(calls[2].args).toContain("install");
expect(calls[3]).toMatchObject(
workload === "gateway"
? { kind: "gateway-build", args: ["qaRuntime"] }
: { kind: "pnpm", args: ["build"] },
);
fixture.assertNoOutputs();
});
it("reaches only the selected build after successful recorder install (positive control)", () => {
const fixture = makePreparationFixture();
const result = fixture.run([workload]);
expect(result.status, result.stderr).toBe(0);
const calls = fixture.calls();
expect(calls).toHaveLength(4);
assert.ok(calls[0]);
assert.ok(calls[1]);
assert.ok(calls[2]);
expect(calls[0].args).toEqual(["--version"]);
expect(calls[1].args).toEqual(["store", "path"]);
expect(calls[2].kind).toBe("pnpm");
expect(calls[2].args).toContain("install");
expect(calls[2].args).toContain("--frozen-lockfile");
expect(calls[2].args).not.toContain("--ignore-scripts");
expect(calls[3]).toMatchObject(
workload === "gateway"
? { kind: "gateway-build", args: ["qaRuntime"] }
: { kind: "pnpm", args: ["build"] },
);
if (workload === "full") {
expect(calls[2].args).toEqual(["install", "--frozen-lockfile"]);
}
for (const call of calls) {
expect(fs.realpathSync(call.cwd)).toBe(fs.realpathSync(fixture.rootDir));
}
fixture.assertNoOutputs();
});
});