From 8185e8b44fff55eba9429f2d027bb0964059778c Mon Sep 17 00:00:00 2001 From: Martin Patino Date: Tue, 13 Oct 2026 15:35:34 -0700 Subject: [PATCH] fix: isolate Bun startup for external lanes Project dotenv files, Bun preloads, and inherited startup options could run before the external lane runner checked routing and credentials. - Move the TypeScript entrypoint behind a shell launcher that clears BUN_OPTIONS and NODE_OPTIONS and disables automatic env/config loading. - Invoke the launcher directly from OMP and subprocess test helpers so the startup isolation also applies to cancellation and explicit deadlines. - Cover individual and combined startup inputs with synthetic fixtures, including exported credentials and child identity preservation. Refs #37. Ports the launcher isolation from open-pstack PR #113. --- .../skills/poteto-mode/scripts/omp/parent.ts | 4 +- .../poteto-mode/scripts/runner/entry.ts | 5 ++ .../poteto-mode/scripts/runner/pstack-runner | 8 +-- .../poteto-mode/scripts/runner/run.test.ts | 29 ++++----- .../scripts/runner/startup.test.ts | 65 +++++++++++++++++++ tests/omp-live.py | 2 +- 6 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts create mode 100644 plugins/pstack/skills/poteto-mode/scripts/runner/startup.test.ts diff --git a/plugins/pstack/skills/poteto-mode/scripts/omp/parent.ts b/plugins/pstack/skills/poteto-mode/scripts/omp/parent.ts index 36f99442..3229fed3 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/omp/parent.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/omp/parent.ts @@ -60,11 +60,9 @@ export default function parent(pi: Extension): void { const output = join(directory, "output.txt"); const receipt = join(directory, "receipt.json"); writeFileSync(prompt, args.prompt, { mode: 0o600 }); - const bun = Bun.which("bun"); - if (!bun) throw new Error("pstack_task requires Bun on PATH"); const runner = process.env.PSTACK_OMP_RUNNER; if (!runner) throw new Error("Start OMP through pstack-omp to dispatch workers"); - const child = Bun.spawn([bun, runner, + const child = Bun.spawn([runner, "--parent", "omp", "--provider", "omp", "--model", choice.model, "--effort", choice.effort, "--mode", args.mode, "--cwd", args.cwd, "--prompt", prompt, "--output", output, "--receipt", receipt, "--label", args.role, 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..94e5dca3 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env bun +export {}; +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/pstack-runner b/plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner index 7cd5a6e9..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 bun -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 a50b5991..efc42df0 100644 --- a/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts @@ -550,7 +550,7 @@ describe("runLane", () => { 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, @@ -609,7 +609,7 @@ describe("runLane", () => { 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, @@ -634,7 +634,7 @@ describe("runLane", () => { 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", @@ -657,7 +657,7 @@ describe("runLane", () => { ...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", @@ -720,7 +720,7 @@ describe("runLane", () => { 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, @@ -751,7 +751,7 @@ describe("runLane", () => { 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, @@ -782,7 +782,7 @@ describe("runLane", () => { 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, @@ -815,7 +815,7 @@ describe("runLane", () => { 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", @@ -838,7 +838,6 @@ describe("runLane", () => { cpSync(join(import.meta.dir, "../omp"), join(scratch, "omp"), { recursive: true }); cpSync(join(import.meta.dir, "../harnesses.ts"), join(scratch, "harnesses.ts")); const runner = Bun.spawn([ - process.execPath, join(isolatedRunner, "pstack-runner"), ...runnerArgs(input).slice(1), ], { @@ -872,7 +871,7 @@ describe("runLane", () => { 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, @@ -913,7 +912,7 @@ describe("runLane", () => { 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", @@ -935,7 +934,7 @@ describe("runLane", () => { 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", @@ -984,7 +983,7 @@ describe("runLane", () => { 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", @@ -1005,7 +1004,7 @@ describe("runLane", () => { 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", @@ -1019,7 +1018,7 @@ describe("runLane", () => { 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, diff --git a/plugins/pstack/skills/poteto-mode/scripts/runner/startup.test.ts b/plugins/pstack/skills/poteto-mode/scripts/runner/startup.test.ts new file mode 100644 index 00000000..81890202 --- /dev/null +++ b/plugins/pstack/skills/poteto-mode/scripts/runner/startup.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +describe("runner startup isolation", () => { + for (const source of ["bun options", "node options", "dotenv", "bunfig", "combined"] as const) { + it(`ignores ${source} while preserving exported child variables`, () => { + const cwd = mkdtempSync(join(tmpdir(), "pstack startup ")); + directories.push(cwd); + const bin = join(cwd, "bin"); + mkdirSync(bin); + const marker = join(cwd, "preload-ran"); + const preload = join(cwd, "preload.cjs"); + writeFileSync(preload, `require("node:fs").writeFileSync(${JSON.stringify(marker)}, "executed");`); + const executable = join(bin, "codex"); + writeFileSync(executable, `#!/bin/sh +if [ "$1" = login ]; then + echo 'Logged in using ChatGPT' + exit 0 +fi +env > "$PSTACK_TEST_ENV" +echo '{"type":"item.completed","item":{"type":"agent_message","text":"STARTUP_OK"}}' +echo '{"type":"turn.completed","usage":{"input_tokens":1,"output_tokens":1}}' +`); + chmodSync(executable, 0o755); + if (source === "dotenv" || source === "combined") writeFileSync(join(cwd, ".env"), "PSTACK_DOTENV_SENTINEL=project-only\n"); + if (source === "bunfig" || source === "combined") writeFileSync(join(cwd, "bunfig.toml"), `preload = [${JSON.stringify(preload)}]\n`); + const prompt = join(cwd, "prompt.md"); + writeFileSync(prompt, "Return STARTUP_OK."); + const output = join(cwd, "output.txt"); + const receipt = join(cwd, "receipt.json"); + const dump = join(cwd, "child.env"); + const env = { + ...process.env, + PATH: `${bin}:${dirname(process.execPath)}:/usr/bin:/bin`, + PSTACK_TEST_ENV: dump, + OPENAI_API_KEY: "synthetic-exported-key", + CODEX_THREAD_ID: "synthetic-parent-identity", + BUN_OPTIONS: source === "bun options" || source === "combined" ? `--preload=${JSON.stringify(preload)}` : "", + NODE_OPTIONS: source === "node options" || source === "combined" ? `--require=${JSON.stringify(preload)}` : "", + }; + const result = Bun.spawnSync([ + join(import.meta.dir, "pstack-runner"), + "--parent", "claude", "--provider", "codex", "--model", "gpt-5.6-sol", "--effort", "max", + "--mode", "read-only", "--cwd", cwd, "--prompt", prompt, "--output", output, "--receipt", receipt, + ], { cwd, env }); + expect(result.exitCode).toBe(0); + expect(readFileSync(output, "utf8")).toContain("STARTUP_OK"); + expect(JSON.parse(readFileSync(receipt, "utf8"))).toMatchObject({ status: "complete", parent: "claude", provider: "codex" }); + expect(existsSync(marker)).toBe(false); + const childEnv = readFileSync(dump, "utf8"); + expect(childEnv).not.toContain("PSTACK_DOTENV_SENTINEL="); + expect(childEnv).not.toContain("BUN_OPTIONS="); + expect(childEnv).not.toContain("NODE_OPTIONS="); + expect(childEnv).toContain("OPENAI_API_KEY=synthetic-exported-key\n"); + expect(childEnv).toContain("CODEX_THREAD_ID=synthetic-parent-identity\n"); + }); + } +}); diff --git a/tests/omp-live.py b/tests/omp-live.py index 61afc50e..5683f539 100644 --- a/tests/omp-live.py +++ b/tests/omp-live.py @@ -100,7 +100,7 @@ def do_POST(self): if scenario == 'missing-model': selected = 'pstack-test/missing' effort = 'max' if scenario == 'unsupported-effort' else 'off' if scenario == 'non-reasoning' else 'high' - argv = [bun, str(scripts / 'runner/pstack-runner'), '--parent', 'omp', '--provider', 'omp', + argv = [str(scripts / 'runner/pstack-runner'), '--parent', 'omp', '--provider', 'omp', '--model', selected, '--effort', effort, '--mode', 'isolated-write' if scenario in ('outside-write', 'inside-write') else 'read-only', '--cwd', str(work), '--prompt', str(root / 'prompt'), '--output', str(output), '--receipt', str(receipt)]