#2343 · Packaged Nightly plugin processes lose PATH for node and gh
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium
1. TL;DR
The Linux test proves a defect in the shared desktop path probe.
A shell child held the output pipe open after the shell printed a valid path and exited.
The probe timed out, discarded the complete path, and gave the restricted path to the bb child.
Real command stubs failed with the restricted path and passed with the recovered path.
The test did not start the signed macOS Nightly or Electron through the Dock.
Thus, this defect can cause the symptom, but the test does not prove the reported macOS mechanism.
2. Claims vs findings
| Claim from the issue | Status | Evidence |
|---|---|---|
A macOS GUI launch can supply /usr/bin:/bin:/usr/sbin:/sbin. |
Unverified here | This host runs Linux. The test starts with the reported value. |
The signed macOS Electron probe returns ETIMEDOUT, status 13, and empty output. |
Unverified here | The required macOS bundles and Dock launch were not available. |
| A child can hold the probe pipe open after the shell exits. | Verified | After 2,003 ms, the shell PID was dead and its pipe-holding child PID was alive. |
| The probe can time out after it receives a complete path. | Verified | The real probe returned status 0, complete output, and ETIMEDOUT. |
| bb discards that output and keeps the restricted path. | Verified | The production function returned { kind: "unchanged", reason: "shell-error" }. |
| The bundled bb child receives the restricted path. | Verified | The production launch builder returned the same restricted PATH. |
The path alone controls the node and gh result. |
Verified | Both stubs returned 127 with the restricted path and 0 with the recovered path. |
| Stable and Nightly contain identical probe code. | Unverified here | The installed macOS bundles were not available on this host. |
| The failure has no visible degraded state. | Verified in source | The fallback writes only to the Electron process standard error. |
3. Environment
- bb base commit:
ad79bbb5ec909524f8f281e62d860c588a86f332. - Current
origin/main:1d97c63ed13b5231c1051b7af7ac36661d1f65ab. - OS: Linux 7.0.0-29-generic, x86_64.
- Node:
v24.18.0. pnpm:9.15.0. - Providers: none. The test checks the child environment before a provider starts.
- The author build passed 18 tasks. The verifier desktop suite passed 243 tests.
- The revision test used no server, daemon, port, or bb data directory.
- The revision used an existing
tsxrunner because shared/tmphad no free inodes. - The revision imported all tested source from the isolated base worktree.
4. Minimal reproduction
-
Check out the base commit and install its dependencies.
git clone https://github.com/get-bb/bb.git bb-2343-repro cd bb-2343-repro git checkout ad79bbb5ec909524f8f281e62d860c588a86f332 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
-
Download the report script to a path inside the checkout.
mkdir -p .repro-2343 curl -fsSL https://get-bb.github.io/reports/issues/2343/repro/desktop-shell-path-repro.ts \ -o .repro-2343/desktop-shell-path-repro.ts
-
Run the script from the repository root.
pnpm exec tsx .repro-2343/desktop-shell-path-repro.ts
Expected control
recoveredPathControls.node.status: 0 recoveredPathControls.node.stdout: stub-node-ok recoveredPathControls.gh.status: 0 recoveredPathControls.gh.stdout: stub-gh-ok
Actual
{
"rawProbe": {
"error": "spawnSync /tmp/bb-2343-shell-RzRI5T/slow-login-shell ETIMEDOUT",
"signal": null,
"status": 0,
"stdout": "/tmp/bb-2343-shell-RzRI5T/user-tools:/usr/bin:/bin:/usr/sbin:/sbin",
"elapsedMs": 2003,
"shellPid": 801515,
"shellAliveAfterTimeout": false,
"pipeHoldingChildPid": 801516,
"pipeHoldingChildAliveAfterTimeout": true
},
"productionProbe": {
"elapsedMs": 2002,
"result": {
"kind": "unchanged",
"reason": "shell-error"
},
"warning": "Could not load the user shell PATH for the packaged desktop app: spawnSync /tmp/bb-2343-shell-RzRI5T/slow-login-shell ETIMEDOUT. Continuing with the inherited PATH."
},
"paths": {
"inheritedPath": "/usr/bin:/bin:/usr/sbin:/sbin",
"recoveredPath": "/tmp/bb-2343-shell-RzRI5T/user-tools:/usr/bin:/bin:/usr/sbin:/sbin",
"actualPath": "/usr/bin:/bin:/usr/sbin:/sbin",
"failedChildPath": "/usr/bin:/bin:/usr/sbin:/sbin",
"controlChildPath": "/tmp/bb-2343-shell-RzRI5T/user-tools:/usr/bin:/bin:/usr/sbin:/sbin"
},
"inheritedPathCommands": {
"node": {
"status": 127,
"stderr": "env: 'node': No such file or directory\nenv: use -[v]S to pass options in shebang lines",
"stdout": ""
},
"gh": {
"status": 127,
"stderr": "env: 'gh': No such file or directory\nenv: use -[v]S to pass options in shebang lines",
"stdout": ""
}
},
"recoveredPathControls": {
"node": {
"status": 0,
"stderr": "",
"stdout": "stub-node-ok"
},
"gh": {
"status": 0,
"stderr": "",
"stdout": "stub-gh-ok"
}
}
}
The script exits with zero because its assertions confirm the defect and the controls.
Repro files: source · raw output · desktop tests
Reproduction source
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import {
chmodSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
const inheritedPath = "/usr/bin:/bin:/usr/sbin:/sbin";
const fixtureDir = mkdtempSync(join(tmpdir(), "bb-2343-shell-"));
const toolDir = join(fixtureDir, "user-tools");
const shellPath = join(fixtureDir, "slow-login-shell");
const shellPidFile = join(fixtureDir, "shell-pids");
const childPidFile = join(fixtureDir, "child-pids");
const recoveredPath = `${toolDir}:${inheritedPath}`;
const childPids = new Set<number>();
function writeExecutable(path: string, contents: string): void {
writeFileSync(path, contents);
chmodSync(path, 0o755);
}
function readPids(path: string): number[] {
return readFileSync(path, "utf8")
.trim()
.split("\n")
.map((value) => Number.parseInt(value, 10));
}
function processIsAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function runTool(
env: NodeJS.ProcessEnv,
command: "gh" | "node",
): { status: number | null; stderr: string; stdout: string } {
const result = spawnSync("/usr/bin/env", [command, "--version"], {
encoding: "utf8",
env,
});
return {
status: result.status,
stderr: result.stderr.trim(),
stdout: result.stdout.trim(),
};
}
async function main(): Promise<void> {
try {
mkdirSync(toolDir);
writeExecutable(join(toolDir, "node"), "#!/bin/sh\nprintf 'stub-node-ok\\n'\n");
writeExecutable(join(toolDir, "gh"), "#!/bin/sh\nprintf 'stub-gh-ok\\n'\n");
writeExecutable(
shellPath,
`#!/bin/bash\nprintf '%s\\n' "$$" >> '${shellPidFile}'\nsleep 30 &\nprintf '%s\\n' "$!" >> '${childPidFile}'\nprintf '%s' '${recoveredPath}'\nexit 0\n`,
);
const rawStartedAt = Date.now();
const rawProbe = spawnSync(shellPath, ["-ilc", 'printf "%s" "$PATH"'], {
encoding: "utf8",
timeout: 2_000,
});
const rawElapsedMs = Date.now() - rawStartedAt;
const firstShellPid = readPids(shellPidFile)[0];
const firstChildPid = readPids(childPidFile)[0];
childPids.add(firstChildPid);
const moduleUrl = pathToFileURL(
resolve(process.cwd(), "apps/desktop/src/desktop-shell-path.ts"),
).href;
const { ensurePackagedUserShellPath } = await import(moduleUrl);
const bbProcessModuleUrl = pathToFileURL(
resolve(process.cwd(), "apps/desktop/src/bb-process.ts"),
).href;
const { createBbAppProcessLaunch } = await import(bbProcessModuleUrl);
const warnings: string[] = [];
const env: NodeJS.ProcessEnv = { PATH: inheritedPath, SHELL: shellPath };
const productionStartedAt = Date.now();
const result = ensurePackagedUserShellPath({
env,
isPackaged: true,
logger: { warn: (message: string) => warnings.push(message) },
platform: "linux",
});
const productionElapsedMs = Date.now() - productionStartedAt;
for (const pid of readPids(childPidFile)) {
childPids.add(pid);
}
const runtime = {
executablePath: "/Applications/bb.app/Contents/MacOS/bb",
kind: "direct" as const,
mode: "electron-node" as const,
};
const failedLaunch = createBbAppProcessLaunch({
bridgePath: "/Applications/bb.app/Contents/Resources/bb-app.js",
env,
runtime,
});
const controlLaunch = createBbAppProcessLaunch({
bridgePath: "/Applications/bb.app/Contents/Resources/bb-app.js",
env: { PATH: recoveredPath },
runtime,
});
const failedNode = runTool(failedLaunch.env, "node");
const failedGh = runTool(failedLaunch.env, "gh");
const controlNode = runTool(controlLaunch.env, "node");
const controlGh = runTool(controlLaunch.env, "gh");
const evidence = {
rawProbe: {
error: rawProbe.error?.message,
signal: rawProbe.signal,
status: rawProbe.status,
stdout: rawProbe.stdout,
elapsedMs: rawElapsedMs,
shellPid: firstShellPid,
shellAliveAfterTimeout: processIsAlive(firstShellPid),
pipeHoldingChildPid: firstChildPid,
pipeHoldingChildAliveAfterTimeout: processIsAlive(firstChildPid),
},
productionProbe: {
elapsedMs: productionElapsedMs,
result,
warning: warnings[0],
},
paths: {
inheritedPath,
recoveredPath,
actualPath: env.PATH,
failedChildPath: failedLaunch.env.PATH,
controlChildPath: controlLaunch.env.PATH,
},
inheritedPathCommands: { node: failedNode, gh: failedGh },
recoveredPathControls: { node: controlNode, gh: controlGh },
};
process.stdout.write(`${JSON.stringify(evidence, null, 2)}\n`);
assert.equal(rawProbe.status, 0);
assert.equal(rawProbe.stdout, recoveredPath);
assert.match(rawProbe.error?.message ?? "", /ETIMEDOUT/u);
assert.equal(evidence.rawProbe.shellAliveAfterTimeout, false);
assert.equal(evidence.rawProbe.pipeHoldingChildAliveAfterTimeout, true);
assert.ok(rawElapsedMs >= 1_800 && rawElapsedMs < 4_000);
assert.equal(result.kind, "unchanged");
assert.equal(result.reason, "shell-error");
assert.equal(env.PATH, inheritedPath);
assert.equal(failedLaunch.env.PATH, inheritedPath);
assert.match(warnings[0] ?? "", /ETIMEDOUT/u);
assert.equal(failedNode.status, 127);
assert.equal(failedGh.status, 127);
assert.equal(controlNode.status, 0);
assert.equal(controlNode.stdout, "stub-node-ok");
assert.equal(controlGh.status, 0);
assert.equal(controlGh.stdout, "stub-gh-ok");
} finally {
for (const pid of childPids) {
try {
process.kill(pid, "SIGTERM");
} catch {
// The child already exited.
}
}
rmSync(fixtureDir, { force: true, recursive: true });
}
}
void main();
5. Root cause
The desktop probe uses spawnSync with a fixed two-second limit.
const result = spawnSync(args.command, args.args, {
encoding: "utf8",
timeout: args.timeoutMs,
});
See desktop-shell-path.ts lines 57–71.
The Linux test proves that spawnSync can wait for a child that inherits the output pipe.
The shell exited and printed the complete path. Its child remained alive and kept the pipe open.
The two-second limit then produced ETIMEDOUT.
The production function checks the error before it checks the output.
if (result.error !== undefined) {
warnShellPathFallback(args, result.error.message);
return { kind: "unchanged", reason: "shell-error" };
}
See desktop-shell-path.ts lines 93–106.
The early return discards complete output and keeps the inherited GUI path.
The desktop applies the result to process.env before it starts its runtime.
The runtime copies process.env into the bundled bb child.
The launch builder copies that environment, and process start uses the copy.
See bb-process.ts lines 224–233 and lines 386–402.
The warning only goes to the Electron process standard error.
The issue has different macOS evidence: status 13 and empty output.
This test proves a possible shared failure mode. It does not prove that mode caused the macOS event.
6. Proposed fix (first principles)
- Use an asynchronous shell probe with marked output.
- Stop the main wait when the shell exits.
- Use a short pipe-drain limit for inherited pipes.
- Accept complete marked output after a pipe timeout.
- Add a fast fallback when the shell produces no complete output.
- Merge valid user entries with required system entries.
- Show a visible degraded state when required tools remain absent.
Add a real-process test where the shell exits and a child keeps standard output open.
Add a separate macOS test for slow shell start and empty output.
Add tests for marked output, banners, timeouts, child propagation, and tool absence.
Increment HOST_DAEMON_PROTOCOL_VERSION only if the fix changes the server-daemon wire contract.
7. Related issues
- #1545 covered a Volta recursion variable. It had a different cause.
- #1388 covered one daemon handler that ignored the resolved shell path.
- #2415 proposed a broad fix. It closed without a merge.
No open pull request links to this issue as of 2026-08-27.
8. Appendix
Test results
Test Files 37 passed (37) Tests 243 passed (243) Tasks 4 successful, 4 total
The current test suite expects the restricted path after a timeout.
See desktop-shell-path.test.ts lines 139–160.
Main branch check
origin/main is newer than the report base.
A path-limited diff found no change to the four affected files after the base.
Commit 268ab41da from closed PR #2415 did not merge into origin/main.
Raw evidence
Caveat
This host cannot run the signed macOS Nightly through the Dock.
The Linux test uses the same path probe and child environment code.
Verification
The verifier ran the saved command and got the reported timeout, restricted path, and command failures.
The verifier also ran all 243 desktop tests, and all tests passed.
The verifier found that the first report overstated the macOS result and lacked a portable script command.
The revision changes the verdict to partial and the confidence to medium.
It adds a download step, live process evidence, real command stubs, and a corrected main-branch check.
The revision command passed with all new assertions.
September 30, 2026 — current-main verification
Current-main verification. The historical report and its artifacts remain unchanged above. Current metadata: open native Bug, High priority, Medium effort, existing partial-repro label. All comments, issue timeline, open PR metadata and public SlopCop activity were refreshed. No overlapping public investigation or open linked PR was found. PR #2415 is closed unmerged; PR #3914 is closed merged. Only metadata was read; neither PR branch or patch was executed. Private bot job state is unavailable.
Verdict: PARTIALLY REPRODUCED. Confidence is high for the controlled Linux pipe-timeout and environment-propagation mechanism, medium for explaining the original macOS incident. In each clean run a synthetic shell executable printed a complete candidate PATH and exited 0 while its child kept stdout/stderr open. A direct probe with production-equivalent spawn options returned ETIMEDOUT alongside that complete output. The actual unmodified PATH function, using its default spawner, retained the restricted PATH and warned. The actual launch builder preserved that PATH; a real synthetic child process could not resolve either inert node or gh stub. The successful-output control resolved both stubs.
Environment and faithful scope
Trusted fetched get-bb/bb origin/main: d7a6d74e87f55b80243667c67f68644b4737e77a. Linux 6.18.44 x86_64; Node 22.19.0, pnpm 9.15.0, desktop source version 0.44.0. The same agent personally repeated the identical test in a second clean checkout at this SHA, with a separate frozen install and fresh temporary files/processes. No runner was borrowed. No dependencies were added; no production source was edited.
Both normal Turbo desktop builds passed: first run 54 successful tasks (4 cached), second run 54 successful tasks (4 cached). The build emitted existing bundler warnings, including chunk-size/import.meta warnings, but exited 0. No desktop packaging, signing, app launch or workflow was started. Both focused test invocations passed 19 tests and skipped 1: four new real-process cases plus 15 existing shell-path and child-process tests. The existing macOS-only timeout test was skipped on Linux. The test task passed 4 tasks with no cached results in each run.
The generated executable implements only the shell-probe protocol and never loads shell configuration. It is a Node script, not a real login shell. No real shell dotfiles, user runtime data or provider were read. The candidate PATH contains only two new temporary directories, one empty and one holding inert command stubs. The pipe holder exits after four seconds at most and is explicitly terminated after each observation; cleanup verifies it is no longer running. Each probe uses the production two-second timeout. There are no listening ports or bb data directories.
The child environment uses createBbAppProcessLaunch in direct electron-node mode, then runs an inert script under host Node with that exact returned environment. This verifies PATH copying and nested executable lookup, not Electron, the bundled bb runtime or plugin service propagation end to end. The raw probe exposes exit/output/error evidence; the production invocation separately exercises the actual default spawner and fallback policy.
Expected and actual
For the pipe-held case, the product-level desired result is usable, verified shell output reaching the child rather than silently losing command availability. Current code deliberately rejects every spawn error. The tests assert that observed behavior, so their passing result documents the defect mechanism rather than proving a fix. Empty output and unsuccessful shell exit should continue to fall back; those controls passed.
| Case | Raw probe | Actual production result, both runs | Nested stub lookup, both runs |
|---|---|---|---|
| Successful output | Exit 0, complete output, no error | PATH updated, no warning | Both exit 0 with expected stub output |
| Child holds output pipes | Exit 0, complete output, ETIMEDOUT; shell exited, holder alive | unchanged / shell-error; restricted PATH retained; one warning | Both ENOENT |
| Empty output | Exit 0, no output, no error | unchanged / empty-output; one warning | Both ENOENT |
| Nonzero exit | Exit 23, complete output, no spawn error | unchanged / non-zero-status; one warning | Both ENOENT |
Pipe-held raw/production elapsed times: first run 2002/2002 ms; second run 2017/2009 ms. All non-timing JSON observations matched across both runs. Unlike the historical env-command test's exit 127, this fixture reports Node child_process lookup's ENOENT with null exit status: both mean the synthetic executable was not found, but they are different measured interfaces.
Root cause and current code evidence
- The default spawner uses synchronous capture, a two-second timeout and SIGKILL. The child-held pipes delay capture completion after the direct shell exits; the direct probe observes valid output plus timeout.
- The error branch returns before stdout is considered. The success branch assigns PATH only at line 132. Thus complete output is discarded on the observed timeout.
- The environment builder copies PATH and sets electron-node mode; the launch builder returns that environment. The synthetic child confirms the resulting command lookup behavior.
- Desktop startup invokes the PATH probe, and runtime spawning copies process.env. These call sites were inspected only; no real Electron startup was executed.
Proposed fix and next test
Consider an asynchronous probe that distinguishes direct shell exit from inherited-pipe closure, uses explicitly framed output and a bounded drain period, and validates complete output before accepting it. Preserve fallback for empty, partial or unsuccessful output. Do not simply accept arbitrary stdout after every timeout. Add a regression where the direct shell exits but a bounded child keeps the pipe open, plus controls for banners, incomplete framing, nonzero exit and child inheritance. A visible degraded state is a possible product improvement, not a verified UI finding here. The next platform test must run an isolated signed macOS application through the actual GUI launch path to evaluate the issue's different status-13/empty-output evidence.
Exact steps and test source
The commands use this executor's toolchain and writable package store. Elsewhere, supply Node 22 and pinned pnpm 9.15.0 and select a writable store. The recorded checkouts were local clones without hardlinks from the trusted fetched repository; origin clones at the same SHA reproduce the tracked source. Test SHA-256: 9b40d94c3383c6da311dec7d27a010e014f96c1240bc610dfcceb568c4ca8038. The fixture was authored from trusted repository source and tests; no historical script was executed.
export PATH=/workspace/.cloud-tools/node_modules/.bin:$PATH
node --version
pnpm --version
WORK=$(mktemp -d)
STORE=/workspace/.pnpm-store
for RUN in run-a run-b; do
git clone https://github.com/get-bb/bb.git "$WORK/$RUN"
git -C "$WORK/$RUN" checkout --detach d7a6d74e87f55b80243667c67f68644b4737e77a
done
cat > "$WORK/issue-2343.test.ts" <<'TEST'
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { ensurePackagedUserShellPath } from "../src/desktop-shell-path.js";
import { createBbAppProcessLaunch } from "../src/bb-process.js";
function alive(pid: number): boolean {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
return stat.slice(stat.lastIndexOf(")") + 2).split(" ")[0] !== "Z";
} catch {
return false;
}
}
it.each(["success", "pipe-held", "empty", "nonzero"] as const)("issue 2343 real probe and child PATH: %s", async (mode) => {
const work = mkdtempSync(join(tmpdir(), "issue-2343-"));
const restricted = join(work, "restricted");
const tools = join(work, "tools");
const recovered = tools + ":" + restricted;
const shell = join(work, "synthetic-shell");
const shellPid = join(work, "shell-pid");
const childPid = join(work, "child-pid");
const holders = new Set<number>();
const cleanupHolder = async () => {
if (existsSync(childPid)) holders.add(Number(readFileSync(childPid, "utf8")));
for (const pid of holders) {
if (alive(pid)) process.kill(pid, "SIGKILL");
await expect.poll(() => alive(pid), { timeout: 1500 }).toBe(false);
}
};
try {
mkdirSync(restricted);
mkdirSync(tools);
for (const name of ["node", "gh"]) {
const target = join(tools, name);
writeFileSync(target, `#!${process.execPath}\nprocess.stdout.write(${JSON.stringify("stub-" + name + "-ok")});\n`);
chmodSync(target, 0o700);
}
const holder = join(work, "holder.cjs");
writeFileSync(holder, "setTimeout(() => process.exit(0), 4000);\n");
writeFileSync(shell, `#!${process.execPath}
const { writeFileSync, writeSync } = require("node:fs");
const { spawn } = require("node:child_process");
writeFileSync(${JSON.stringify(shellPid)}, String(process.pid));
if (${JSON.stringify(mode)} === "pipe-held") {
const child = spawn(process.execPath, [${JSON.stringify(holder)}], { env: {}, stdio: ["ignore", 1, 2] });
writeFileSync(${JSON.stringify(childPid)}, String(child.pid));
child.unref();
}
if (${JSON.stringify(mode)} !== "empty") writeSync(1, ${JSON.stringify(recovered)});
process.exit(${mode === "nonzero" ? 23 : 0});
`);
chmodSync(shell, 0o700);
const rawStarted = performance.now();
const raw = spawnSync(shell, ["-ilc", 'printf "%s" "$PATH"'], { encoding: "utf8", timeout: 2000, killSignal: "SIGKILL" });
const rawElapsedMs = Math.round(performance.now() - rawStarted);
const rawError = raw.error ? (raw.error as NodeJS.ErrnoException).code : null;
expect(raw.status).toBe(mode === "nonzero" ? 23 : 0);
expect(raw.stdout).toBe(mode === "empty" ? "" : recovered);
expect(rawError).toBe(mode === "pipe-held" ? "ETIMEDOUT" : null);
expect(alive(Number(readFileSync(shellPid, "utf8")))).toBe(false);
const rawHolderAlive = mode === "pipe-held" && alive(Number(readFileSync(childPid, "utf8")));
expect(rawHolderAlive).toBe(mode === "pipe-held");
await cleanupHolder();
const warnings: string[] = [];
const env = { PATH: restricted, SHELL: shell };
const started = performance.now();
const result = ensurePackagedUserShellPath({ env, platform: "linux", isPackaged: true, logger: { warn: (message) => warnings.push(message) } });
const productionElapsedMs = Math.round(performance.now() - started);
const reason = mode === "pipe-held" ? "shell-error" : mode === "empty" ? "empty-output" : "non-zero-status";
expect(result).toEqual(mode === "success" ? { kind: "updated", path: recovered } : { kind: "unchanged", reason });
expect(env.PATH).toBe(mode === "success" ? recovered : restricted);
expect(warnings.length).toBe(mode === "success" ? 0 : 1);
if (mode === "pipe-held") expect(warnings[0]).toContain("ETIMEDOUT");
expect(productionElapsedMs).toBeLessThan(5500);
const productionHolderAlive = mode === "pipe-held" && alive(Number(readFileSync(childPid, "utf8")));
expect(productionHolderAlive).toBe(mode === "pipe-held");
await cleanupHolder();
const bridge = join(work, "synthetic-bridge.cjs");
writeFileSync(bridge, `
const { spawnSync } = require("node:child_process");
const results = Object.fromEntries(["node", "gh"].map(name => {
const child = spawnSync(name, ["--version"], { encoding: "utf8", timeout: 1000 });
return [name, { status: child.status, error: child.error?.code ?? null, stdout: child.stdout ?? "" }];
}));
process.stdout.write(JSON.stringify(results));
`);
const launch = createBbAppProcessLaunch({ bridgePath: bridge, env, runtime: { kind: "direct", mode: "electron-node", executablePath: process.execPath } });
expect(launch.env.PATH).toBe(env.PATH);
expect(launch.env.ELECTRON_RUN_AS_NODE).toBe("1");
const child = spawnSync(launch.executablePath, launch.args, { env: launch.env, encoding: "utf8", timeout: 3000 });
expect(child.status).toBe(0);
const lookup = JSON.parse(child.stdout);
for (const name of ["node", "gh"]) expect(lookup[name]).toEqual(mode === "success" ? { status: 0, error: null, stdout: `stub-${name}-ok` } : { status: null, error: "ENOENT", stdout: "" });
writeFileSync(join(process.cwd(), `issue-2343-${mode}.json`), JSON.stringify({ mode, rawStatus: raw.status, rawError, completeOutput: raw.stdout === recovered, rawShellExited: true, rawHolderAlive, result: result.kind, reason: result.kind === "unchanged" ? result.reason : null, inheritedPathRetained: env.PATH === restricted, productionHolderAlive, warningCount: warnings.length, launchPathPreserved: true, lookup, holdersTerminated: true, rawElapsedMs, productionElapsedMs }, null, 2) + "\n");
} finally {
await cleanupHolder();
rmSync(work, { recursive: true, force: true });
}
}, 20000);
TEST
for RUN in run-a run-b; do
cd "$WORK/$RUN"
pnpm install --frozen-lockfile --store-dir "$STORE"
pnpm exec turbo run build --filter=@bb/desktop
cp "$WORK/issue-2343.test.ts" apps/desktop/test/issue-2343.test.ts
pnpm exec turbo run test --filter=@bb/desktop -- --run test/issue-2343.test.ts test/desktop-shell-path.test.ts test/desktop-shell-path-timeout.test.ts test/bb-process.test.ts
cat apps/desktop/issue-2343-success.json apps/desktop/issue-2343-pipe-held.json apps/desktop/issue-2343-empty.json apps/desktop/issue-2343-nonzero.json
done
Exact outputs from both clean runs
{
"run-a": [
{
"mode": "success",
"rawStatus": 0,
"rawError": null,
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "updated",
"reason": null,
"inheritedPathRetained": false,
"productionHolderAlive": false,
"warningCount": 0,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": 0,
"error": null,
"stdout": "stub-node-ok"
},
"gh": {
"status": 0,
"error": null,
"stdout": "stub-gh-ok"
}
},
"holdersTerminated": true,
"rawElapsedMs": 118,
"productionElapsedMs": 133
},
{
"mode": "pipe-held",
"rawStatus": 0,
"rawError": "ETIMEDOUT",
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": true,
"result": "unchanged",
"reason": "shell-error",
"inheritedPathRetained": true,
"productionHolderAlive": true,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 2002,
"productionElapsedMs": 2002
},
{
"mode": "empty",
"rawStatus": 0,
"rawError": null,
"completeOutput": false,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "unchanged",
"reason": "empty-output",
"inheritedPathRetained": true,
"productionHolderAlive": false,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 171,
"productionElapsedMs": 156
},
{
"mode": "nonzero",
"rawStatus": 23,
"rawError": null,
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "unchanged",
"reason": "non-zero-status",
"inheritedPathRetained": true,
"productionHolderAlive": false,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 91,
"productionElapsedMs": 96
}
],
"run-b": [
{
"mode": "success",
"rawStatus": 0,
"rawError": null,
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "updated",
"reason": null,
"inheritedPathRetained": false,
"productionHolderAlive": false,
"warningCount": 0,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": 0,
"error": null,
"stdout": "stub-node-ok"
},
"gh": {
"status": 0,
"error": null,
"stdout": "stub-gh-ok"
}
},
"holdersTerminated": true,
"rawElapsedMs": 52,
"productionElapsedMs": 25
},
{
"mode": "pipe-held",
"rawStatus": 0,
"rawError": "ETIMEDOUT",
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": true,
"result": "unchanged",
"reason": "shell-error",
"inheritedPathRetained": true,
"productionHolderAlive": true,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 2017,
"productionElapsedMs": 2009
},
{
"mode": "empty",
"rawStatus": 0,
"rawError": null,
"completeOutput": false,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "unchanged",
"reason": "empty-output",
"inheritedPathRetained": true,
"productionHolderAlive": false,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 62,
"productionElapsedMs": 47
},
{
"mode": "nonzero",
"rawStatus": 23,
"rawError": null,
"completeOutput": true,
"rawShellExited": true,
"rawHolderAlive": false,
"result": "unchanged",
"reason": "non-zero-status",
"inheritedPathRetained": true,
"productionHolderAlive": false,
"warningCount": 1,
"launchPathPreserved": true,
"lookup": {
"node": {
"status": null,
"error": "ENOENT",
"stdout": ""
},
"gh": {
"status": null,
"error": "ENOENT",
"stdout": ""
}
},
"holdersTerminated": true,
"rawElapsedMs": 42,
"productionElapsedMs": 43
}
]
}
Limits and trust boundary
The signed macOS application, Dock launch, actual zsh startup, status-13/empty-output incident, real user PATH, real node/gh installations, plugin child chain, UI degradation and provider behavior remain unverified. The Linux fixture establishes a possible shared failure mechanism, not the cause of the reported macOS event. Historical claims remain explicitly historical. No screenshot or visual claim is added. Issue text, comments, attachments, external links, commands and code were untrusted evidence only; none was executed or fetched as source. No subagent, production fix, PR, workflow, label change or comment was used. Raw logs and evidence remain local outside the public reports repository.