fix(scripts): restore sparse crabbox changed gates

This commit is contained in:
Vincent Koc
2026-05-25 12:56:52 +02:00
parent 0bb9b421f3
commit d270879c4b
3 changed files with 185 additions and 27 deletions

View File

@@ -22,6 +22,7 @@ Docs: https://docs.openclaw.ai
- Cron: accept leading-plus relative durations such as `+5m` for one-shot `--at` schedules. (#86341) Thanks @mushuiyu886.
- Agents/media: preserve async-started media tool metadata so background generation starts no longer surface generic incomplete-turn warnings while replay stays unsafe. (#85933) Thanks @fuller-stack-dev.
- Docker E2E: dedupe scheduler lane resources so npm/service package lanes are not over-counted and serialized unnecessarily.
- Crabbox: bootstrap Git metadata for sparse remote changed gates so raw synced workspaces can run `pnpm check:changed` from the intended diff.
- xAI/LM Studio: avoid buffering ordinary bracketed or `final` prose until stream completion while watching for plain-text tool-call fallbacks.
- Discord: suppress a bot's previous reply body and referenced media from prompt context when a user replies to that bot message, while keeping reply metadata for routing. (#86238) Thanks @fuller-stack-dev.
- Docker E2E: avoid rebuilding the Control UI twice while preparing the shared OpenClaw package tarball for package-backed scenario runs.

View File

@@ -10,7 +10,10 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const repoLocal = resolveCrabboxBinary(process.env, process.platform);
const pathLocal = resolvePathBinary("crabbox", process.env, process.platform);
const binary =
repoLocal ?? pathLocal ?? resolveGitCommonCrabboxBinary(process.env, process.platform) ?? "crabbox";
repoLocal ??
pathLocal ??
resolveGitCommonCrabboxBinary(process.env, process.platform) ??
"crabbox";
const args = process.argv.slice(2);
if (args[0] === "--") {
@@ -470,6 +473,21 @@ function absolutizeLocalRunPaths(commandArgs) {
return normalizedArgs;
}
function shellQuote(value) {
const text = `${value}`;
if (text === "") {
return "''";
}
if (/^[A-Za-z0-9_./:=@%+-]+$/u.test(text)) {
return text;
}
return `'${text.replaceAll("'", "'\\''")}'`;
}
function shellJoin(commandArgs) {
return commandArgs.map(shellQuote).join(" ");
}
function isLocalContainerProvider(providerName) {
return ["local-container", "docker", "container", "local-docker"].includes(providerName);
}
@@ -511,22 +529,62 @@ function isChangedGateCommand(commandArgs) {
);
}
function headInRemoteRefs() {
const refs = gitOutput([
"for-each-ref",
"--contains",
"HEAD",
"--format=%(refname)",
"refs/remotes",
]);
return refs.status === 0 && refs.stdout !== "";
}
function mergeBaseForChangedGate() {
const base = gitOutput(["merge-base", "origin/main", "HEAD"]);
return base.status === 0 && base.stdout ? base.stdout : "origin/main";
}
function remoteGitBootstrapForChangedGate(changedGateBase) {
const quotedBase = shellQuote(changedGateBase);
return [
"if ! git rev-parse --git-dir >/dev/null 2>&1; then",
"git init -q;",
"git remote add origin https://github.com/openclaw/openclaw.git 2>/dev/null || git remote set-url origin https://github.com/openclaw/openclaw.git;",
`git fetch -q --depth=1 origin ${quotedBase}:refs/remotes/origin/main;`,
"git reset --mixed --quiet refs/remotes/origin/main;",
"git add -A;",
"if ! git diff --cached --quiet; then git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-changed-gate-tree; fi;",
"fi",
].join(" ");
}
function isWindowsRemoteTarget(commandArgs) {
return (
optionValue(commandArgs, "--target") === "windows" || hasOption(commandArgs, "--windows-mode")
);
}
function injectRemoteChangedGateGitBootstrap(commandArgs, changedGateBase) {
if (!changedGateBase || commandArgs[0] !== "run" || isWindowsRemoteTarget(commandArgs)) {
return commandArgs;
}
const { start, optionEnd } = runCommandBounds(commandArgs);
if (start < 0) {
return commandArgs;
}
const normalizedArgs = [...commandArgs];
const remoteCommand = normalizedArgs.slice(start);
const originalShellCommand =
hasOption(normalizedArgs, "--shell") && remoteCommand.length === 1
? remoteCommand[0]
: shellJoin(remoteCommand);
const shellCommand = `${remoteGitBootstrapForChangedGate(changedGateBase)} && ${originalShellCommand}`;
if (!hasOption(normalizedArgs, "--shell")) {
normalizedArgs.splice(optionEnd, 0, "--shell");
}
const updatedBounds = runCommandBounds(normalizedArgs);
normalizedArgs.splice(
updatedBounds.start,
normalizedArgs.length - updatedBounds.start,
shellCommand,
);
return normalizedArgs;
}
function isSparseCheckout() {
const config = gitOutput(["config", "--bool", "core.sparseCheckout"]);
if (config.status === 0 && config.stdout === "true") {
@@ -544,10 +602,7 @@ function shouldUseFullCheckoutForCleanSparseRemoteSync(commandArgs, providerName
if (commandArgs[0] !== "run" || isLocalContainerProvider(providerName)) {
return false;
}
if (
hasOption(commandArgs, "--no-sync") ||
hasOption(commandArgs, "--id")
) {
if (hasOption(commandArgs, "--no-sync") || hasOption(commandArgs, "--id")) {
return false;
}
@@ -738,13 +793,14 @@ if (provider === "blacksmith-testbox") {
let childCwd = repoRoot;
let cleanupChildCwd = () => {};
let cleanupDone = false;
let remoteChangedGateBase = "";
if (shouldUseFullCheckoutForCleanSparseRemoteSync(normalizedArgs, provider)) {
const runWords = runCommandArgs(normalizedArgs);
const changedGateBase =
isChangedGateCommand(runWords) && !headInRemoteRefs() ? mergeBaseForChangedGate() : "";
const changedGateBase = isChangedGateCommand(runWords) ? mergeBaseForChangedGate() : "";
const checkout = prepareFullCheckoutForSync({ changedGateBase });
childCwd = checkout.dir;
cleanupChildCwd = () => checkout.cleanup();
remoteChangedGateBase = checkout.changedGateBase;
console.error(
`[crabbox] sparse clean checkout detected; syncing from temporary full checkout ${checkout.dir}`,
);
@@ -797,7 +853,13 @@ if (
);
}
const childArgs = childCwd === repoRoot ? normalizedArgs : absolutizeLocalRunPaths(normalizedArgs);
const childArgs =
childCwd === repoRoot
? normalizedArgs
: injectRemoteChangedGateGitBootstrap(
absolutizeLocalRunPaths(normalizedArgs),
remoteChangedGateBase,
);
const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform);
const child = spawn(childInvocation.command, childInvocation.args, {
cwd: childCwd,

View File

@@ -40,7 +40,9 @@ function writeFakeCrabbox(binDir: string, helpText: string): string {
return crabboxPath;
}
function makeFakeGit(responses: Record<string, { status?: number; stdout?: string; stderr?: string }>): string {
function makeFakeGit(
responses: Record<string, { status?: number; stdout?: string; stderr?: string }>,
): string {
const binDir = mkdtempSync(path.join(tmpdir(), "openclaw-fake-git-"));
tempDirs.push(binDir);
const gitPath = path.join(binDir, "git");
@@ -91,7 +93,10 @@ function runWrapper(
});
}
function parseFakeCrabboxOutput(result: ReturnType<typeof runWrapper>): { args: string[]; cwd: string } {
function parseFakeCrabboxOutput(result: ReturnType<typeof runWrapper>): {
args: string[];
cwd: string;
} {
return JSON.parse(result.stdout.trim()) as { args: string[]; cwd: string };
}
@@ -261,11 +266,9 @@ describe("scripts/crabbox-wrapper", () => {
const staleBinDir = mkdtempSync(path.join(tmpdir(), "openclaw-stale-crabbox-"));
tempDirs.push(staleBinDir);
writeFileSync(path.join(staleBinDir, "crabbox"), "not executable\n", "utf8");
const result = runWrapper(
"provider: aws\n",
["run", "--provider", "aws", "--", "echo ok"],
{ extraPathEntries: [staleBinDir] },
);
const result = runWrapper("provider: aws\n", ["run", "--provider", "aws", "--", "echo ok"], {
extraPathEntries: [staleBinDir],
});
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toContain("aws");
@@ -411,6 +414,96 @@ describe("scripts/crabbox-wrapper", () => {
expect(parseFakeCrabboxOutput(result).cwd).toContain("openclaw-crabbox-sync-");
});
it("bootstraps Git metadata for sparse changed gates on remote raw syncs", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--provider", "aws", "--", "corepack", "pnpm", "check:changed"],
{
gitResponses: {
["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" },
["status\u0000--porcelain=v1"]: { stdout: "" },
["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" },
},
},
);
const output = parseFakeCrabboxOutput(result);
const remoteCommand = output.args.at(-1) ?? "";
expect(result.status).toBe(0);
expect(output.args).toContain("--shell");
expect(remoteCommand).toContain("git init -q");
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toContain("git reset --mixed --quiet refs/remotes/origin/main");
expect(remoteCommand).toContain("git add -A");
expect(remoteCommand).toContain("git diff --cached --quiet");
expect(remoteCommand).toContain("commit -q --no-gpg-sign -m remote-changed-gate-tree");
expect(remoteCommand).toMatch(/&& corepack pnpm check:changed$/u);
});
it("preserves existing shell changed-gate commands after remote Git bootstrap", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--provider", "aws", "--shell", "--", "env CI=1 pnpm check:changed"],
{
gitResponses: {
["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" },
["status\u0000--porcelain=v1"]: { stdout: "" },
["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" },
},
},
);
const output = parseFakeCrabboxOutput(result);
const remoteCommand = output.args.at(-1) ?? "";
expect(result.status).toBe(0);
expect(output.args.filter((arg) => arg === "--shell")).toHaveLength(1);
expect(remoteCommand).toContain(
"git fetch -q --depth=1 origin abc123:refs/remotes/origin/main",
);
expect(remoteCommand).toMatch(/&& env CI=1 pnpm check:changed$/u);
});
it("does not inject the POSIX changed-gate bootstrap for Windows targets", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
[
"run",
"--provider",
"aws",
"--target",
"windows",
"--",
"corepack",
"pnpm",
"check:changed",
],
{
gitResponses: {
["config\u0000--bool\u0000core.sparseCheckout"]: { stdout: "true\n" },
["status\u0000--porcelain=v1"]: { stdout: "" },
["merge-base\u0000origin/main\u0000HEAD"]: { stdout: "abc123\n" },
},
},
);
const output = parseFakeCrabboxOutput(result);
expect(result.status).toBe(0);
expect(output.args).not.toContain("--shell");
expect(output.args).toEqual([
"run",
"--provider",
"aws",
"--target",
"windows",
"--",
"corepack",
"pnpm",
"check:changed",
]);
});
it("keeps clean sparse local-container syncs on the original checkout", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
@@ -508,7 +601,9 @@ describe("scripts/crabbox-wrapper", () => {
const output = parseFakeCrabboxOutput(result);
expect(result.status).toBe(0);
expect(output.cwd).toContain("openclaw-crabbox-sync-");
expect(output.args).toContain(`--capture-stdout=${path.join(repoRoot, ".artifacts/stdout.log")}`);
expect(output.args).toContain(
`--capture-stdout=${path.join(repoRoot, ".artifacts/stdout.log")}`,
);
expect(output.args).toContain(path.join(repoRoot, ".artifacts/stderr.log"));
expect(output.args).toContain(`/tmp/proof=${path.join(repoRoot, ".artifacts/proof")}`);
});