From 141a84e6216702296ce4cacbb7c88497d1371b17 Mon Sep 17 00:00:00 2001 From: "mastra-platform[bot]" <284800079+mastra-platform[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 12:26:27 +0000 Subject: [PATCH 1/3] fix: preserve parent authentication at runner startup (#25) Block project dotenv, Bun configuration and inherited startup-option influence before runner code, while retaining parent environment and launcher-owned deadlines. Cover the shipped executable directly, including preflight cancellation. Co-Authored-By: mastra-platform[bot] <284800079+mastra-platform[bot]@users.noreply.github.com> --- CHANGES.md | 4 ++ .../poteto-mode/scripts/runner/pstack-runner | 2 +- .../poteto-mode/scripts/runner/run.test.ts | 57 ++++++++++++++++++- 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index d84b2acc..a4ff4256 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -3,6 +3,10 @@ Mergify auto-queue is live: ready PRs to main require passing verify, Unfret and exact-head live-gate; workflow changes require operator queueing. This port applies the Cursor → Claude Code substitutions in skill bodies. Earlier drafts left them flagged; this revision resolves them. A later pass added a Codex build that shares the same skills; see [Codex port](#codex-port) below. +## Unreleased + +The shipped `pstack-runner` no longer loads the caller project's environment files or `bunfig.toml` at startup. Its hardened Bun shebang clears inherited `BUN_OPTIONS` and `NODE_OPTIONS`, disables env-file loading, and ignores project Bun configuration while preserving ordinary parent-provided environment variables. The launcher keeps its existing first-statement deadline capture and signal behavior. Direct-executable regressions cover startup isolation, inherited environment, and preflight cancellation. + ## 1.5.0 syncs to Cursor pstack 0.15.5 Open Pstack 1.5.0 tracks Cursor pstack 0.15.5 at `12d587dfb20741cafc376c42c696c5f6e2a64487`. The first-run panel is now three models: `claude:opus@max`, `codex:gpt-5.6-sol@max`, and `grok:grok-4.7@xhigh`. Opus max takes judgment and prose, hardest tasks, and the How explainer. Grok 4.7 xhigh takes feature and refactoring work, the How explorer, and Swarm workers. Bug-fix, perf-issue, and hillclimb stay on Sol max. Why and Reflect stay on `inherit-parent`. Fable stays in the model matrix with its native agents, but no first-run role uses it. Setup asks about roles first, then asks efforts for and probes only the assigned families (the ordering follows PR #73 by @arjitj2). It drops and lists retired-role rows such as `how critics`. diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner index 7cd5a6e9..0caeafe2 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner @@ -1,4 +1,4 @@ -#!/usr/bin/env bun +#!/usr/bin/env -S -u BUN_OPTIONS -u NODE_OPTIONS bun --no-env-file --config=/dev/null const startedAt = Date.now(); const { main } = await import("./cli.ts"); process.exitCode = await main(process.argv.slice(2), startedAt); diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index 20743b52..cc5da4b7 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -272,6 +272,62 @@ afterEach(() => { }); describe("runLane", () => { + for (const startup of ["dotenv", "bunfig", "bun-options", "node-options", "combined"]) { + it(`shipped launcher startup isolation: ${startup}`, async () => { + const input = options("codex", startup); + const captured = join(scratch, "provider-env.txt"); + const bunPreload = join(scratch, "bun-preload.ran"); + const nodePreload = join(scratch, "node-preload.ran"); + const env: Record = { + PATH: process.env.PATH!, + PSTACK_ENV_CAPTURE: captured, + PSTACK_INHERITED_ENV_SENTINEL: "inherited-from-parent", + }; + if (startup === "dotenv" || startup === "combined") { + writeFileSync(join(scratch, ".env"), "PSTACK_PROJECT_ENV_SENTINEL=loaded-from-project-dotenv\n"); + writeFileSync(join(scratch, ".env.local"), "PSTACK_LOCAL_ENV_SENTINEL=loaded-from-project-local-dotenv\n"); + } + if (startup === "bunfig" || startup === "combined") { + writeFileSync(join(scratch, "bunfig.toml"), 'preload = ["./project-preload.ts"]\n'); + writeFileSync(join(scratch, "project-preload.ts"), + `await Bun.write(${JSON.stringify(bunPreload)}, "project preload ran");\n`); + } + if (startup === "bun-options" || startup === "combined") { + writeFileSync(join(scratch, "startup.env"), "PSTACK_PROJECT_ENV_SENTINEL=loaded-from-bun-options\n"); + env.BUN_OPTIONS = "--env-file=./startup.env"; + } + if (startup === "node-options" || startup === "combined") { + writeFileSync(join(scratch, "project-preload.cjs"), + `require("node:fs").writeFileSync(${JSON.stringify(nodePreload)}, "node preload ran");\n`); + env.NODE_OPTIONS = "--require=./project-preload.cjs"; + } + writeFileSync(join(bin, "codex"), `#!/bin/sh +if [ "$1" = "login" ]; then + printf '%s\\n' 'Logged in using ChatGPT' + exit 0 +fi +printf '%s\\n' "\${PSTACK_PROJECT_ENV_SENTINEL-unset}" "\${PSTACK_LOCAL_ENV_SENTINEL-unset}" "\${PSTACK_INHERITED_ENV_SENTINEL-unset}" "\${BUN_OPTIONS-unset}" "\${NODE_OPTIONS-unset}" > "$PSTACK_ENV_CAPTURE" +printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item.completed","item":{"type":"agent_message","text":"CODEX_OK"}}' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}' +`); + chmodSync(join(bin, "codex"), 0o755); + const runner = Bun.spawn(runnerArgs(input), { + cwd: scratch, + env, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = new Response(runner.stdout).text(); + const stderr = new Response(runner.stderr).text(); + expect(await exitWithin(runner, 3_000)).toBe(0); + await Promise.all([stdout, stderr]); + expect(readFileSync(captured, "utf8")).toBe("unset\nunset\ninherited-from-parent\nunset\nunset\n"); + expect(existsSync(bunPreload)).toBe(false); + expect(existsSync(nodePreload)).toBe(false); + expect(readFileSync(input.outputPath, "utf8")).toContain("CODEX_OK"); + expect(receipt(input.receiptPath)).toMatchObject({ status: "complete" }); + }); + } + for (const provider of ["claude", "codex", "grok"] as const) { it(`executes and receipts the ${provider} external lane`, async () => { const input = options(provider); @@ -683,7 +739,6 @@ describe("runLane", () => { const isolatedRunner = join(scratch, "isolated-runner"); cpSync(import.meta.dir, isolatedRunner, { recursive: true }); const runner = Bun.spawn([ - process.execPath, join(isolatedRunner, "pstack-runner"), ...runnerArgs(input).slice(1), ], { From 42a4b17b9f341d3a1be336d0bb7ac12619889e14 Mon Sep 17 00:00:00 2001 From: "mastra-platform[bot]" <284800079+mastra-platform[bot]@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:48:43 +0000 Subject: [PATCH 2/3] test: drain provider stdin to avoid launcher fixture races The shell fake Codex could exit before Bun finished delivering its prompt. Consume and verify the full prompt, exceed pipe capacity to exercise delivery, and include failure receipts and stderr without weakening isolation assertions. Co-Authored-By: mastra-platform[bot] <284800079+mastra-platform[bot]@users.noreply.github.com> --- .../skills/poteto-mode/scripts/runner/run.test.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index cc5da4b7..1aa431ac 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -275,12 +275,16 @@ describe("runLane", () => { for (const startup of ["dotenv", "bunfig", "bun-options", "node-options", "combined"]) { it(`shipped launcher startup isolation: ${startup}`, async () => { const input = options("codex", startup); + // Exceed pipe capacity so the fake provider must consume stdin before exiting. + writeFileSync(input.promptPath, "Return the marker.\n".repeat(100_000)); const captured = join(scratch, "provider-env.txt"); + const capturedPrompt = join(scratch, "provider-prompt.txt"); const bunPreload = join(scratch, "bun-preload.ran"); const nodePreload = join(scratch, "node-preload.ran"); const env: Record = { PATH: process.env.PATH!, PSTACK_ENV_CAPTURE: captured, + PSTACK_PROMPT_CAPTURE: capturedPrompt, PSTACK_INHERITED_ENV_SENTINEL: "inherited-from-parent", }; if (startup === "dotenv" || startup === "combined") { @@ -306,6 +310,7 @@ if [ "$1" = "login" ]; then printf '%s\\n' 'Logged in using ChatGPT' exit 0 fi +cat > "$PSTACK_PROMPT_CAPTURE" printf '%s\\n' "\${PSTACK_PROJECT_ENV_SENTINEL-unset}" "\${PSTACK_LOCAL_ENV_SENTINEL-unset}" "\${PSTACK_INHERITED_ENV_SENTINEL-unset}" "\${BUN_OPTIONS-unset}" "\${NODE_OPTIONS-unset}" > "$PSTACK_ENV_CAPTURE" printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item.completed","item":{"type":"agent_message","text":"CODEX_OK"}}' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}' `); @@ -318,8 +323,14 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item }); const stdout = new Response(runner.stdout).text(); const stderr = new Response(runner.stderr).text(); - expect(await exitWithin(runner, 3_000)).toBe(0); - await Promise.all([stdout, stderr]); + const exitCode = await exitWithin(runner, 3_000); + const [stdoutText, stderrText] = await Promise.all([stdout, stderr]); + expect(exitCode, JSON.stringify({ + stdout: stdoutText, + stderr: stderrText, + receipt: existsSync(input.receiptPath) ? receipt(input.receiptPath) : null, + })).toBe(0); + expect(readFileSync(capturedPrompt, "utf8")).toBe(readFileSync(input.promptPath, "utf8")); expect(readFileSync(captured, "utf8")).toBe("unset\nunset\ninherited-from-parent\nunset\nunset\n"); expect(existsSync(bunPreload)).toBe(false); expect(existsSync(nodePreload)).toBe(false); From 0be4b216a5c037642cc44b6313a8b01a056967a4 Mon Sep 17 00:00:00 2001 From: "mastra-platform[bot]" <284800079+mastra-platform[bot]@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:35:05 +0000 Subject: [PATCH 3/3] fix: keep runner startup portable on BusyBox hosts (#25) Replace the env -S dependency with a POSIX launcher using only shell builtins before exec. Keep startup isolation and signal behavior, and cover hostile PATH helpers and bare-name invocation. Co-Authored-By: mastra-platform[bot] <284800079+mastra-platform[bot]@users.noreply.github.com> --- CHANGES.md | 2 +- .../poteto-mode/scripts/runner/entry.ts | 4 ++ .../poteto-mode/scripts/runner/pstack-runner | 8 ++-- .../poteto-mode/scripts/runner/run.test.ts | 44 ++++++++++++------- 4 files changed, 36 insertions(+), 22 deletions(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts diff --git a/CHANGES.md b/CHANGES.md index a4ff4256..e6cd0b8a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,7 @@ This port applies the Cursor → Claude Code substitutions in skill bodies. Earl ## Unreleased -The shipped `pstack-runner` no longer loads the caller project's environment files or `bunfig.toml` at startup. Its hardened Bun shebang clears inherited `BUN_OPTIONS` and `NODE_OPTIONS`, disables env-file loading, and ignores project Bun configuration while preserving ordinary parent-provided environment variables. The launcher keeps its existing first-statement deadline capture and signal behavior. Direct-executable regressions cover startup isolation, inherited environment, and preflight cancellation. +The shipped `pstack-runner` no longer loads the caller project's environment files or `bunfig.toml` at startup. Its POSIX shell launcher clears inherited `BUN_OPTIONS` and `NODE_OPTIONS`, then executes Bun with env-file loading disabled and project Bun configuration ignored while preserving ordinary parent-provided environment variables. It needs no `env -S` or directory-resolution helpers. The TypeScript entry keeps the first-statement deadline capture, and `exec` preserves signal behavior. Direct-executable regressions cover startup isolation, inherited environment, and preflight cancellation. ## 1.5.0 syncs to Cursor pstack 0.15.5 diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts new file mode 100644 index 00000000..65cb38a4 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts @@ -0,0 +1,4 @@ +const startedAt = Date.now(); +const { main } = await import("./cli.ts"); +process.exitCode = await main(process.argv.slice(2), startedAt); +export {}; diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner index 0caeafe2..55e3c8b8 100755 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner @@ -1,4 +1,4 @@ -#!/usr/bin/env -S -u BUN_OPTIONS -u NODE_OPTIONS bun --no-env-file --config=/dev/null -const startedAt = Date.now(); -const { main } = await import("./cli.ts"); -process.exitCode = await main(process.argv.slice(2), startedAt); +#!/bin/sh +unset BUN_OPTIONS NODE_OPTIONS +case $0 in */*) dir=${0%/*} ;; *) dir=. ;; esac +exec bun --no-env-file --config=/dev/null "$dir/entry.ts" "$@" diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts index 1aa431ac..8aba5f39 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -272,7 +272,7 @@ afterEach(() => { }); describe("runLane", () => { - for (const startup of ["dotenv", "bunfig", "bun-options", "node-options", "combined"]) { + for (const startup of ["dotenv", "bunfig", "bun-options", "node-options", "combined", "hostile-path", "bare-name"]) { it(`shipped launcher startup isolation: ${startup}`, async () => { const input = options("codex", startup); // Exceed pipe capacity so the fake provider must consume stdin before exiting. @@ -315,8 +315,17 @@ printf '%s\\n' "\${PSTACK_PROJECT_ENV_SENTINEL-unset}" "\${PSTACK_LOCAL_ENV_SENT printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item.completed","item":{"type":"agent_message","text":"CODEX_OK"}}' '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}' `); chmodSync(join(bin, "codex"), 0o755); - const runner = Bun.spawn(runnerArgs(input), { - cwd: scratch, + const helperSentinel = join(scratch, "helper.ran"); + if (startup === "hostile-path") { + for (const name of ["env", "dirname", "readlink"]) { + writeFileSync(join(bin, name), `#!/bin/sh\nprintf '%s\\n' '${name}' > "${helperSentinel}"\nexit 1\n`); + chmodSync(join(bin, name), 0o755); + } + } + const runner = Bun.spawn(startup === "bare-name" + ? ["/bin/sh", "pstack-runner", ...runnerArgs(input).slice(1)] + : runnerArgs(input), { + cwd: startup === "bare-name" ? import.meta.dir : scratch, env, stdout: "pipe", stderr: "pipe", @@ -334,6 +343,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item expect(readFileSync(captured, "utf8")).toBe("unset\nunset\ninherited-from-parent\nunset\nunset\n"); expect(existsSync(bunPreload)).toBe(false); expect(existsSync(nodePreload)).toBe(false); + expect(existsSync(helperSentinel)).toBe(false); expect(readFileSync(input.outputPath, "utf8")).toContain("CODEX_OK"); expect(receipt(input.receiptPath)).toMatchObject({ status: "complete" }); }); @@ -464,7 +474,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item const transientMarker = join(scratch, "grok-cancel-unauth.pid"); const preflightLog = join(scratch, "grok-cancel-unauth.log"); const input = options("grok", "grok-preflight-retry-cancelled"); - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -523,7 +533,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("does not spawn the model when preflight exhausts the wrapper deadline", async () => { const modelStarted = join(scratch, "deadline-model.started"); const input = { ...options("claude", "preflight-deadline"), timeoutMs: 300 }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -548,7 +558,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("lets a delayed wrapper lane finish when timeout is omitted", async () => { const input = options("claude", "unbounded-default"); - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, FAKE_MODEL_DELAY_MS: "400" }, stdout: "pipe", @@ -571,7 +581,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item ...options("claude", "long-runtime-deadline"), timeoutMs: 2_147_483_648, }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, FAKE_MODEL_DELAY_MS: "100" }, stdout: "pipe", @@ -634,7 +644,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("bounds a descendant-held pipe by the explicit deadline without fabricating a signal", async () => { const descendantPidPath = join(scratch, "deadline-descendant.pid"); const input = { ...options("claude", "deadline-drain"), timeoutMs: 700 }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -665,7 +675,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("does not claim a signal was sent to an already signal-reaped child", async () => { const descendantPidPath = join(scratch, "signalled-descendant.pid"); const input = { ...options("claude", "signalled-drain"), timeoutMs: 700 }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -696,7 +706,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item const descendantPidPath = join(scratch, "cancel-descendant.pid"); const modelExiting = join(scratch, "cancel-model.exiting"); const input = options("claude", "cancel-drain"); - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -729,7 +739,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("clears a losing long-deadline timer when the shipped wrapper succeeds", async () => { const input = { ...options("claude", "long-deadline"), timeoutMs: 60_000 }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env }, stdout: "pipe", @@ -783,7 +793,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item const input = { ...options("codex", "repeated-cancel"), timeoutMs: 60_000 }; const started = join(scratch, "repeated-child.started"); const terminated = join(scratch, "repeated-child.terminated"); - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env, @@ -824,7 +834,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item FAKE_STARTED_PATH: started, FAKE_TERMINATED_PATH: terminated, }; - const runner = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const runner = Bun.spawn(runnerArgs(input), { cwd: scratch, env, stdout: "pipe", @@ -846,7 +856,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item error: { message: "launcher received SIGTERM; signal was sent to child" }, }); - const samePaths = Bun.spawn([process.execPath, ...runnerArgs(input)], { + const samePaths = Bun.spawn(runnerArgs(input), { cwd: scratch, env: { ...process.env }, stdout: "pipe", @@ -895,7 +905,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item it("terminalizes catchable failures after reserving output paths", async () => { const unreadable = options("claude", "unreadable-prompt"); chmodSync(unreadable.promptPath, 0o000); - const unreadableRunner = Bun.spawn([process.execPath, ...runnerArgs(unreadable)], { + const unreadableRunner = Bun.spawn(runnerArgs(unreadable), { cwd: scratch, env: { ...process.env }, stdout: "pipe", @@ -916,7 +926,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item error: { message: "launcher failed after reserving output paths" }, }); - const samePaths = Bun.spawn([process.execPath, ...runnerArgs(unreadable)], { + const samePaths = Bun.spawn(runnerArgs(unreadable), { cwd: scratch, env: { ...process.env }, stdout: "pipe", @@ -930,7 +940,7 @@ printf '%s\\n' '{"type":"thread.started","thread_id":"isolated"}' '{"type":"item const spawnFailure = options("claude", "spawn-failure"); const modelStarted = join(scratch, "spawn-failure-model.started"); - const spawnRunner = Bun.spawn([process.execPath, ...runnerArgs(spawnFailure)], { + const spawnRunner = Bun.spawn(runnerArgs(spawnFailure), { cwd: scratch, env: { ...process.env,