diff --git a/scripts/crabbox-wrapper.mjs b/scripts/crabbox-wrapper.mjs index fab7351d373..96d8a0424f4 100755 --- a/scripts/crabbox-wrapper.mjs +++ b/scripts/crabbox-wrapper.mjs @@ -134,6 +134,15 @@ const shellControlCommandPrefixes = new Set([ "!", ]); const shellCommandExecutionPrefixes = new Set(["exec"]); +const shellInlineCommandInterpreters = new Set(["bash", "dash", "ksh", "sh", "zsh"]); +const shellInlineCommandOptionsWithNextValue = new Set([ + "+O", + "+o", + "-O", + "-o", + "--init-file", + "--rcfile", +]); function escapeBatchCommand(command) { return `${command}`.replace(cmdMetaCharactersRe, "^$1"); @@ -553,18 +562,38 @@ function commandRuntimeEntrypoint(commandArgs) { function commandWordsRuntimeEntrypoint(words) { const first = (words[0] ?? "").split("/").pop(); - return jsRuntimeEntrypoints.has(first) ? first : ""; + if (jsRuntimeEntrypoints.has(first)) { + return first; + } + + const inlineCommand = shellInlineCommand(words); + if (!inlineCommand) { + return ""; + } + for (const candidateWords of shellCommandWordCandidates(inlineCommand)) { + const shellRuntime = commandWordsRuntimeEntrypoint(candidateWords); + if (shellRuntime) { + return shellRuntime; + } + } + return ""; } function isChangedGateCommand(commandArgs) { if (commandArgs.length === 1) { - return shellCommandWordCandidates(commandArgs[0]).some(isChangedGateWords); + return shellCommandWordCandidates(commandArgs[0]).some(isChangedGateCommandWords); } const words = normalizedCommandWords(commandArgs); + return isChangedGateCommandWords(words); +} + +function isChangedGateCommandWords(words) { if (isChangedGateWords(words)) { return true; } - return false; + + const inlineCommand = shellInlineCommand(words); + return inlineCommand ? shellCommandWordCandidates(inlineCommand).some(isChangedGateCommandWords) : false; } function isChangedGateWords(words) { @@ -579,6 +608,34 @@ function isChangedGateWords(words) { ); } +function shellInlineCommand(words) { + const command = shellWordBasename(words[0]); + if (!shellInlineCommandInterpreters.has(command)) { + return ""; + } + + for (let index = 1; index < words.length; index += 1) { + const word = words[index]; + if (word === "--") { + return ""; + } + if (!word.startsWith("-") && !word.startsWith("+")) { + return ""; + } + if (word === "-c" || /^-[^-]*c/u.test(word)) { + return words[index + 1] ?? ""; + } + if (shellInlineCommandOptionConsumesNextValue(word)) { + index += 1; + } + } + return ""; +} + +function shellInlineCommandOptionConsumesNextValue(word) { + return shellInlineCommandOptionsWithNextValue.has(word) || /^[+-][^-+]*[oO]$/u.test(word); +} + function shellCommandWordCandidates(command) { return shellCommandSegments(stripHeredocBodies(command.replace(/\\\r?\n/gu, " "))); } diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index c43df1fbf29..a3e8aa5005b 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -1117,6 +1117,28 @@ describe("scripts/crabbox-wrapper", () => { expectGroupedShellCommand(remoteCommand, shellScript); }); + it("preserves macOS JS and Git bootstraps for shell-wrapped sparse changed gates", () => { + const shellScript = "bash -lc 'pnpm check:changed'"; + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--target", "macos", "--shell", "--", shellScript], + { + 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 = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain("openclaw_crabbox_bootstrap_macos_js"); + expectGroupedShellCommand(remoteCommand, shellScript); + }); + it("preserves sparse changed-gate Git bootstrap for assignment-prefix command substitutions", () => { const shellScript = "TOOL_ROOT=$(pwd) pnpm check:changed"; const result = runWrapper( @@ -1159,6 +1181,94 @@ describe("scripts/crabbox-wrapper", () => { expect(remoteCommand).toContain(`&& ${shellScript}`); }); + it("preserves sparse changed-gate Git bootstrap for bash -lc shell commands", () => { + const shellScript = + "env CI=1 NODE_OPTIONS=--max-old-space-size=4096 bash -lc 'set -euo pipefail; pnpm check:changed'"; + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--shell", "--", shellScript], + { + 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 = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain( + "git fetch -q --depth=1 origin abc123:refs/remotes/origin/main", + ); + expect(remoteCommand).toContain(`&& ${shellScript}`); + }); + + it("preserves sparse changed-gate Git bootstrap for shell option values before -c", () => { + const shellScript = "bash -o pipefail -c 'pnpm check:changed'"; + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--shell", "--", shellScript], + { + 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 = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain(`&& ${shellScript}`); + }); + + it("preserves sparse changed-gate Git bootstrap for attached shell option values before -c", () => { + const shellScript = "bash --rcfile=./ci.bashrc -c 'pnpm check:changed'"; + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--shell", "--", shellScript], + { + 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 = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain(`&& ${shellScript}`); + }); + + it("preserves sparse changed-gate Git bootstrap for grouped shell options before -c", () => { + const shellScript = "bash -eo pipefail -c 'pnpm check:changed'"; + const result = runWrapper( + "provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n", + ["run", "--provider", "aws", "--shell", "--", shellScript], + { + 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 = normalizeShellLineEndings(output.args.at(-1) ?? ""); + expect(result.status).toBe(0); + expect(remoteCommand).toContain("git init -q"); + expect(remoteCommand).toContain(`&& ${shellScript}`); + }); + it("preserves sparse changed-gate Git bootstrap for absolute time-prefixed shell commands", () => { const shellScript = "/usr/bin/time -l pnpm check:changed"; const result = runWrapper(