Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ 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 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.

- Document Linux Landlock and bubblewrap prerequisites for bounded Grok lanes, including affected Claude Code cloud hosts, and link setup failures to the shared host and parent prerequisites without changing runner behavior (#56).

## 1.5.0 syncs to Cursor pstack 0.15.5
Expand Down
4 changes: 4 additions & 0 deletions plugins/pstack/skills/poteto-mode/scripts/runner/entry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const startedAt = Date.now();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Low · Required strict plugin validation was not run

This adds a new packaged plugin entry point, but the supplied verification record says claude plugin validate --strict plugins/pstack could not run. AGENTS.md requires plugin validation before opening a pull request, so this changed package was opened without the mandated validation successfully completing.

const { main } = await import("./cli.ts");
process.exitCode = await main(process.argv.slice(2), startedAt);
export {};
Original file line number Diff line number Diff line change
@@ -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" "$@"
Comment on lines +3 to +4

@unfret-eal unfret-eal Bot Oct 4, 2026 •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Symlinked launcher still resolves entry.ts beside the link

Carried repair, keep-confirmed at head cd0dff6. Line 3 still derives dir from $0 (case $0 in */*) dir=${0%/*}), which is the path the caller invoked, not the resolved file. Trigger: the launcher is reached through a file symlink, for example ~/.local/bin/pstack-runner pointing to the packaged scripts/runner/pstack-runner, and invoked by path or through PATH. The kernel passes the link path as $0, so line 4 runs bun --no-env-file --config=/dev/null ~/.local/bin/entry.ts. Bun exits with module-not-found before preflight. No provider starts and no receipt is written. At base, the deleted #!/usr/bin/env bun entry resolved ./cli.ts from the real module location, so the same symlink worked. The new bare-name case runs from the packaged directory and the SIGINT case runs a copied directory, so neither test exercises a file symlink. Also reported for this defect: - Symlinked launcher resolves entry beside the link (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When pstack-runner is invoked through a file symlink such as ~/.local/bin/pstack-runner, $0 names the symlink, so this computes dir=~/.local/bin; line 4 then asks Bun to execute ~/.local/bin/entry.ts rather than the packaged entry point. Bun exits with a module-not-found error before preflight, provider startup, or receipt creation. The new bare-name test runs from the packaged directory and does not exercise a symlinked installation. - Symlinked launcher resolves entry.ts beside the link (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When pstack-runner is invoked through a file symlink such as ~/.local/bin/pstack-runner, $0 names the symlink, so this selects ~/.local/bin and line 4 asks Bun to run ~/.local/bin/entry.ts. Bun exits with Module not found before preflight or receipt creation, preventing every external lane from starting. The previous Bun entry resolved ./cli.ts from the symlink target's packaged directory. - Symlinked launcher resolves entry beside the link (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When pstack-runner is invoked through a file symlink such as ~/.local/bin/pstack-runner pointing to the packaged launcher, $0 names the symlink, so this assigns ~/.local/bin to dir; the following command then requests ~/.local/bin/entry.ts. Bun exits with Module not found before preflight or receipt creation, preventing every external lane from starting through this common PATH installation. This reproduces on the current head. Also reported for this defect: - Symlinked launcher looks for entry.ts beside the symlink (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): Carried repair, keep-confirmed: the current head still has this defect. Line 3 sets dir from $0 (case $0 in */*) dir=${0%/*}). $0 is the invoked path, not the resolved file. Suppose the launcher is reached through a file symlink, such as ~/.local/bin/pstack-runner pointing to the packaged scripts/runner/pstack-runner. The kernel passes the link path as $0, so line 4 runs bun ... ~/.local/bin/entry.ts. Bun exits with Module not found before preflight, so no provider starts and no receipt is written. The base #!/usr/bin/env bun launcher worked through the same symlink because it resolved ./cli.ts from its real module location. The bare-name test runs from the packaged directory and the SIGINT test uses a copied directory, so neither exercises a file symlink. - Symlinked launcher resolves entry.ts beside the link (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When pstack-runner is invoked through a symlink such as ~/.local/bin/pstack-runner, $0 names the symlink, so this computes dir=~/.local/bin and the next line asks Bun to run ~/.local/bin/entry.ts instead of the packaged entry point. Bun exits with a module-not-found error before any provider starts or receipt is written. The new bare-name test runs from the packaged directory and does not cover this symlink placement. - Symlinked launcher looks for entry.ts beside the symlink (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When the executable is symlinked into a PATH directory, such as ~/.local/bin/pstack-runner, $0 names the symlink and this assignment selects ~/.local/bin rather than the packaged runner directory. The following Bun invocation exits with Module not found ".../bin/entry.ts", so no provider starts and no receipt is written. Reproduced with Bun 1.4.0; the base launcher succeeds through the same symlink because Bun resolves its relative import from the actual script location. Also reported for this defect: - Symlinked launcher searches beside the link (plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4): When invoked through a file symlink such as /usr/local/bin/pstack-runner pointing into the installed plugin, $0 names the link, so this computes /usr/local/bin/entry.ts rather than the target's sibling. Unless that unrelated directory also contains the runner files, Bun exits before preflight or receipt creation. The previous Bun entry resolved its relative import against the actual module, so this introduces a startup regression for symlinked launchers.

Now at plugins/pstack/skills/poteto-mode/scripts/runner/pstack-runner:3-4

106 changes: 91 additions & 15 deletions plugins/pstack/skills/poteto-mode/scripts/runner/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,83 @@ process.exit(${scenario.endsWith("nonzero") ? 1 : 0});
});

describe("runLane", () => {
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.
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<string, string> = {
PATH: process.env.PATH!,
PSTACK_ENV_CAPTURE: captured,
PSTACK_PROMPT_CAPTURE: capturedPrompt,
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
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}}'
`);
chmodSync(join(bin, "codex"), 0o755);
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",
});
const stdout = new Response(runner.stdout).text();
const stderr = new Response(runner.stderr).text();
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);
expect(existsSync(helperSentinel)).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);
Expand Down Expand Up @@ -648,7 +725,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,
Expand Down Expand Up @@ -707,7 +784,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,
Expand All @@ -732,7 +809,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",
Expand All @@ -755,7 +832,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",
Expand Down Expand Up @@ -818,7 +895,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,
Expand Down Expand Up @@ -849,7 +926,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,
Expand Down Expand Up @@ -880,7 +957,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,
Expand Down Expand Up @@ -913,7 +990,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",
Expand All @@ -934,7 +1011,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),
], {
Expand Down Expand Up @@ -968,7 +1044,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,
Expand Down Expand Up @@ -1009,7 +1085,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",
Expand All @@ -1031,7 +1107,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",
Expand Down Expand Up @@ -1080,7 +1156,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",
Expand All @@ -1101,7 +1177,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",
Expand All @@ -1115,7 +1191,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,
Expand Down
Loading