Reports · Issue #4417

Runtime PATH preparation changes which executable owns the name bb

2026-09-30 · Bug · Medium priority · Medium effort

PARTIALLY REPRODUCED · High confidence in the PATH and shebang collision mechanism. Real Babashka, fish, provider sessions and the packaged desktop runtime were not exercised.

Claim and faithful tested scope

The issue alleges that prepending the product CLI directory shadows an existing executable also named bb, including scripts resolved through /usr/bin/env. The test calls the actual prepareRuntimeShellEnv function with explicit synthetic paths, then executes real Linux child processes. Two temporary executable stubs named bb identify themselves with different markers. An executable fixture uses an env-based bb shebang; its body is inert synthetic input ignored by both stubs.

Before preparation, ordinary shell lookup, a bare bb invocation and direct execution of the shebang fixture all select the existing-runtime stub. After preparation, all three select the product-CLI stub. Explicit absolute-path calls still reach either intended stub. BB_CLI also points directly to the product stub. These results are identical in the two clean checkouts.

Expected and actual

Expected regression behavior: introducing the product runtime environment preserves existing bb lookup for ordinary commands and scripts, while the product CLI remains reachable through its explicit path. Actual: PATH begins with product-bin, ahead of existing-bin; command lookup and both execution controls switch to PRODUCT_CLI_STUB. All child processes exit 0 because the stubs intentionally only identify themselves. The report does not claim the real CLI's error message or the real Babashka program's output.

Baseline, explicit-existing and explicit-BB_CLI controls pass. Three desired-behavior assertions fail: lookup path, ordinary command output and shebang interpreter output. This is one failing regression test with three assertion failures, not three test cases.

Environment and boundaries

Fetched public origin/main: 36aacc040ec0a2b092785d65dee7869d02cec08e. Linux x86_64, Node 24.19.0, repository-pinned pnpm 9.15.0, Vitest 4.1.1; /bin/sh resolves to dash and /usr/bin/env is GNU coreutils 9.7. The test explicitly requires Linux. It does not reproduce fish login-shell PATH discovery, aliases/functions, mise shims, macOS, Windows or any real Babashka/Clojure semantics.

Both runs use separate clean checkouts at the same SHA and identical test bytes, separate node_modules and caches, and shared dependency downloads. Every run creates new temporary directories/files and deletes them in finally. Child environments are explicitly constructed from synthetic values without inheriting the parent environment; user/system PATH and installed binaries are not modified. The inherited PATH string is supplied directly, so no login shell or user configuration is read. No daemon, model service, server listener or port is used. The loopback server URL is a required inert function argument.

Exact repeatable steps

git clone https://github.com/get-bb/bb.git bb-repro
cd bb-repro
git checkout --detach 36aacc040ec0a2b092785d65dee7869d02cec08e
corepack pnpm install --frozen-lockfile
corepack pnpm exec turbo run build --filter=@bb/host-daemon --force
# Save the inline test as apps/host-daemon/src/issue-4417.test.ts
corepack pnpm exec turbo run test --filter=@bb/host-daemon --force -- src/issue-4417.test.ts --silent=false

Use pnpm 9.15.0 for subprocesses as well. This environment used a task-local Corepack shim and disposable writable caches. Repeat the same commands and test in a second clean checkout at the identical SHA. Frozen installation and host-daemon builds exit 0; each test command exits 1 with one failed test and the three documented desired-behavior assertion failures. Those failures are the reproduced lookup collision, not setup failures.

apps/host-daemon/src/issue-4417.test.ts

import { spawnSync } from "node:child_process";
import { mkdtemp, mkdir, writeFile, chmod, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, delimiter } from "node:path";
import { expect, it } from "vitest";
import { prepareRuntimeShellEnv } from "./runtime-shell-env.js";

it("4417 preserves the existing bb lookup for ordinary commands and env shebangs", async () => {
  expect(process.platform).toBe("linux");
  const root = await mkdtemp(join(tmpdir(), "synthetic-path-"));
  try {
    const existing = join(root, "existing-bin");
    const product = join(root, "product-bin");
    await mkdir(existing); await mkdir(product);
    for (const [directory, marker] of [[existing, "EXISTING_RUNTIME_STUB"], [product, "PRODUCT_CLI_STUB"]]) {
      const executable = join(directory!, "bb");
      await writeFile(executable, `#!/bin/sh\nprintf '%s\\n' '${marker}'\n`);
      await chmod(executable, 0o755);
    }
    const script = join(root, "synthetic-script");
    await writeFile(script, "#!/usr/bin/env bb\nsynthetic interpreter input; stubs intentionally ignore this body\n");
    await chmod(script, 0o755);
    const inheritedPath = [existing, "/usr/bin", "/bin"].join(delimiter);
    const beforeEnv = { PATH: inheritedPath };
    const prepared = prepareRuntimeShellEnv({ bbExecutableDirectory: product,
      bbExecutablePath: join(product, "bb"), inheritedPath, serverUrl: "http://127.0.0.1:49173" });
    const run = (command: string, args: string[], env: NodeJS.ProcessEnv) => {
      const result = spawnSync(command, args, { env, cwd: root, encoding: "utf8", timeout: 5000 });
      if (result.error) throw result.error;
      expect(result.status).toBe(0); expect(result.stderr).toBe("");
      return { status: result.status, stdout: result.stdout.trim() };
    };
    const baselineLookup = run("/bin/sh", ["-c", "command -v bb"], beforeEnv);
    const baselineCommand = run("/bin/sh", ["-c", "exec bb"], beforeEnv);
    const baselineShebang = run(script, [], beforeEnv);
    const preparedLookup = run("/bin/sh", ["-c", "command -v bb"], prepared);
    const preparedCommand = run("/bin/sh", ["-c", "exec bb"], prepared);
    const preparedShebang = run(script, [], prepared);
    const explicitExisting = run(join(existing, "bb"), [], prepared);
    const explicitProduct = run(prepared.BB_CLI!, [], prepared);
    expect(baselineLookup.stdout).toBe(join(existing, "bb"));
    expect(baselineCommand.stdout).toBe("EXISTING_RUNTIME_STUB");
    expect(baselineShebang.stdout).toBe("EXISTING_RUNTIME_STUB");
    expect(explicitExisting.stdout).toBe("EXISTING_RUNTIME_STUB");
    expect(explicitProduct.stdout).toBe("PRODUCT_CLI_STUB");
    expect(prepared.BB_CLI).toBe(join(product, "bb"));
    expect(beforeEnv.PATH).toBe(inheritedPath);
    const evidence = { phase: "path-and-shebang", platform: process.platform, shell: "/bin/sh", envExecutable: "/usr/bin/env",
      inheritedPath, preparedPath: prepared.PATH, bbCli: prepared.BB_CLI, baselineLookup, baselineCommand,
      baselineShebang, preparedLookup, preparedCommand, preparedShebang, explicitExisting, explicitProduct };
    console.log(JSON.stringify(evidence).replaceAll(root, "<FIXTURE>"));
    expect.soft(preparedLookup.stdout.replaceAll(root, "<FIXTURE>")).toBe("<FIXTURE>/existing-bin/bb");
    expect.soft(preparedCommand.stdout).toBe("EXISTING_RUNTIME_STUB");
    expect.soft(preparedShebang.stdout).toBe("EXISTING_RUNTIME_STUB");
  } finally { await rm(root, { recursive: true, force: true }); }
});

Actual evidence and same-agent second clean reproduction

First clean checkout

{
  "phase": "path-and-shebang",
  "platform": "linux",
  "shell": "/bin/sh",
  "envExecutable": "/usr/bin/env",
  "inheritedPath": "<FIXTURE>/existing-bin:/usr/bin:/bin",
  "preparedPath": "<FIXTURE>/product-bin:<FIXTURE>/existing-bin:/usr/bin:/bin",
  "bbCli": "<FIXTURE>/product-bin/bb",
  "baselineLookup": {
    "status": 0,
    "stdout": "<FIXTURE>/existing-bin/bb"
  },
  "baselineCommand": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "baselineShebang": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "preparedLookup": {
    "status": 0,
    "stdout": "<FIXTURE>/product-bin/bb"
  },
  "preparedCommand": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  },
  "preparedShebang": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  },
  "explicitExisting": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "explicitProduct": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  }
}
AssertionError: expected '<FIXTURE>/product-bin/bb' to be '<FIXTURE>/existing-bin/bb' // Object.is equality
AssertionError: expected 'PRODUCT_CLI_STUB' to be 'EXISTING_RUNTIME_STUB' // Object.is equality
AssertionError: expected 'PRODUCT_CLI_STUB' to be 'EXISTING_RUNTIME_STUB' // Object.is equality
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  210ms (transform 21ms, setup 0ms, import 33ms, tests 28ms, environment 0ms)

Second clean checkout

{
  "phase": "path-and-shebang",
  "platform": "linux",
  "shell": "/bin/sh",
  "envExecutable": "/usr/bin/env",
  "inheritedPath": "<FIXTURE>/existing-bin:/usr/bin:/bin",
  "preparedPath": "<FIXTURE>/product-bin:<FIXTURE>/existing-bin:/usr/bin:/bin",
  "bbCli": "<FIXTURE>/product-bin/bb",
  "baselineLookup": {
    "status": 0,
    "stdout": "<FIXTURE>/existing-bin/bb"
  },
  "baselineCommand": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "baselineShebang": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "preparedLookup": {
    "status": 0,
    "stdout": "<FIXTURE>/product-bin/bb"
  },
  "preparedCommand": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  },
  "preparedShebang": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  },
  "explicitExisting": {
    "status": 0,
    "stdout": "EXISTING_RUNTIME_STUB"
  },
  "explicitProduct": {
    "status": 0,
    "stdout": "PRODUCT_CLI_STUB"
  }
}
AssertionError: expected '<FIXTURE>/product-bin/bb' to be '<FIXTURE>/existing-bin/bb' // Object.is equality
AssertionError: expected 'PRODUCT_CLI_STUB' to be 'EXISTING_RUNTIME_STUB' // Object.is equality
AssertionError: expected 'PRODUCT_CLI_STUB' to be 'EXISTING_RUNTIME_STUB' // Object.is equality
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  210ms (transform 22ms, setup 0ms, import 35ms, tests 27ms, environment 0ms)

The same agent personally ran the final regression in both clean checkouts; this is not independent verification. Both frozen installs and builds succeeded, and both tests produced identical evidence after replacing only each generated temporary directory with <FIXTURE>. That replacement occurs in the test output; marker strings and exit statuses are actual observations. There were no preliminary failed setup attempts. No screenshots are supplied because no UI behavior was tested.

Root cause and trusted source

Fix proposal and next test

Make the product command available through its explicit BB_CLI path or another unambiguous entry point without silently taking over an existing same-name executable. Audit skills/plugins that rely on bare bb before changing precedence; simply appending the directory could send those callers to Babashka instead. Preserve deliberate compatibility behavior with regression coverage for both callers. No production fix was applied.

Next, validate a chosen policy in a fresh isolated fish/provider environment with an actual Babashka installation and representative scripts. Confirm both product commands and existing scripts resolve correctly. The synthetic result establishes why lookup changes; it does not prove the complete reported packaged-runtime failure or a proposed fix.

Trust and publication checks

Issue commands, code, linked external sites, runtime identifiers and suggested fixes were treated solely as untrusted claims. None were executed or accessed. The fixture and stub contents were independently derived from trusted implementation and existing tests. No real user runtime, shell profile, credentials or user-installed executable was accessed or altered. All current comments were read (zero before publication); no existing report or linked PR was found. Public visibility, source links, inline evidence and privacy were checked. Raw logs remain local. The referenced publisher script is absent; direct HTML/reference/privacy checks were used and the generated index was left unchanged.