← reports

#5807 · MCP bypasses the runtime-pinned CLI launcher

Bug Priority: Medium Effort: Low host plugins confirmed-repro

Issue #5807 · October 19, 2026 · trusted base 0eac33e06d71e04d28b4c0eb726353decb6973e0

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

The production MCP CLI runner cannot execute the real bb CLI when its environment lacks Node on PATH, even though a working runtime-pinned launcher is supplied. It selects BB_CLI, whose Node shebang needs PATH, rather than BB_CLI_LAUNCHER, whose shell script invokes an absolute runtime path. The command exits 127; command-index loading then replaces the useful interpreter error with an inaccurate update hint. Two clean checkouts of the same trusted main commit reproduced both symptoms; selecting the existing launcher instead ran version 0.46.1 and loaded all 232 commands. This verifies the failure mechanism, not a live macOS LaunchAgent installation.

2. Claims vs findings

Claim testedFindingEvidence
MCP fails without Node on PATH.VerifiedThe unmodified production runner launches the built CLI and returns 127 with the interpreter-not-found error in both clean checkouts.
An absolute-runtime launcher works in the same environment.VerifiedThe actual launcher renderer produces a script that successfully executes the same real CLI with the same isolated environment.
MCP does not select the supplied launcher.VerifiedProduction resolution selects the Node-shebang CLI; its candidate list does not contain the launcher variable.
The command-index error wrongly suggests updating.VerifiedThe real loader throws the update hint on the failing runner. The launcher-selected control obtains 232 commands from the very same build.
The npm LaunchAgent does not set PATH.Verified statically; macOS execution unverifiedThe trusted install script writes an absolute runtime in ProgramArguments but only npm-prefix and data-directory entries in EnvironmentVariables.
A moved server or another plugin has the same observed failure.UnverifiedNo move, live npm service, or Talk to BB instance was run. An unchanged server PATH is supported by the launcher code; the broader observed incidents are not asserted as reproduced.

3. Environment

4. Minimal reproduction

Start from a clean clone of the public target repository. Save the complete script in the next section as /tmp/reproduce-5807.mts, then run:

git clone https://github.com/get-bb/bb.git /tmp/bb-5807-first
cd /tmp/bb-5807-first
git checkout --detach 0eac33e06d71e04d28b4c0eb726353decb6973e0
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node --conditions=source --import tsx /tmp/reproduce-5807.mts "$PWD"

Expected product behavior: version execution should succeed using the available pinned launcher, and the command index should load without depending on Node being discoverable through PATH.

Actual output, identical in both checkouts:

runtime-pinned launcher: {"exitCode":0,"stdout":"0.46.1\n","stderr":""}
MCP CLI runner: {"exitCode":127,"stdout":"","stderr":"/usr/bin/env: 'node': No such file or directory\n","timedOut":false,"truncated":false}
command-index loader: The bb CLI on this bb server did not return its command index (`bb guide commands --json`), so the `read` and `search` tools are unavailable. Update bb on that machine.
MCP with launcher selected: {"exitCode":0,"stdout":"0.46.1\n","stderr":"","timedOut":false,"truncated":false}
launcher command index: 232 commands
REPRODUCED: real CLI fails only when MCP ignores the pinned launcher.

The script exits zero when it confirms the existing bug and its positive controls. It is a diagnostic reproduction, not a passing regression test for a proposed patch. It imports the real runner, loader, and launcher renderer; there is no fake bb executable or mocked process result.

Complete reproduction script

import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";

const source = resolve(process.argv[2]);
const loadSource = (relativePath: string) =>
  import(pathToFileURL(join(source, relativePath)).href);
const { createCliRunner, resolveBbBinary } = await loadSource(
  "plugins/mcp/src/cli-runner.ts",
);
const { createCommandIndexLoader } = await loadSource(
  "plugins/mcp/src/command-index.ts",
);
const { renderCliLauncher } = await loadSource(
  "packages/bb-app/src/cli-launcher.ts",
);
const temporary = await mkdtemp(join(tmpdir(), "bb-5807-repro-"));
try {
  const emptyPath = join(temporary, "empty-path");
  const dataDir = join(temporary, "data");
  await mkdir(emptyPath);
  await mkdir(dataDir);
  const binary = join(source, "apps/cli/dist/index.js");
  const launcher = join(temporary, "bb-launcher");
  await writeFile(
    launcher,
    renderCliLauncher({
      dataDir,
      platform: process.platform,
      target: {
        kind: "direct",
        runtimePath: process.execPath,
        entryPath: binary,
        electron: false,
      },
    }),
  );
  await chmod(launcher, 0o755);
  const env = {
    PATH: emptyPath,
    HOME: temporary,
    BB_DATA_DIR: dataDir,
    BB_CLI: binary,
    BB_CLI_LAUNCHER: launcher,
  };
  const sanitize = (text: string) => text.replaceAll(temporary, "<temporary>");
  const control = spawnSync(launcher, ["--version"], {
    env,
    encoding: "utf8",
  });
  console.log("runtime-pinned launcher:", JSON.stringify({
    exitCode: control.status,
    stdout: sanitize(control.stdout),
    stderr: sanitize(control.stderr),
  }));
  assert.equal(control.status, 0);
  assert.notEqual(control.stdout.trim(), "");
  assert.equal(await resolveBbBinary(env), binary);
  const runCli = createCliRunner({
    env,
    serverUrl: () => "http://127.0.0.1:1",
  });
  const result = await runCli(["--version"], {
    stdin: null,
    timeoutMs: 10_000,
    signal: new AbortController().signal,
  });
  console.log("MCP CLI runner:", JSON.stringify(result));
  assert.equal(result.exitCode, 127);
  assert.equal(result.stdout, "");
  assert.match(result.stderr, /node.*No such file or directory/);
  let indexError = "";
  try {
    await createCommandIndexLoader(runCli)();
  } catch (error) {
    assert.ok(error instanceof Error);
    indexError = error.message;
  }
  console.log("command-index loader:", indexError);
  assert.match(indexError, /Update bb on that machine/);
  const launcherRun = createCliRunner({
    env: { ...env, BB_CLI: launcher },
    serverUrl: () => "http://127.0.0.1:1",
  });
  const repaired = await launcherRun(["--version"], {
    stdin: null,
    timeoutMs: 10_000,
    signal: new AbortController().signal,
  });
  console.log("MCP with launcher selected:", JSON.stringify(repaired));
  assert.equal(repaired.exitCode, 0);
  assert.equal(repaired.stdout, control.stdout);
  const commandIndex = await createCommandIndexLoader(launcherRun)();
  console.log("launcher command index:", commandIndex.commands.length, "commands");
  assert.ok(commandIndex.commands.length > 0);
  console.log("REPRODUCED: real CLI fails only when MCP ignores the pinned launcher.");
} finally {
  await rm(temporary, { recursive: true, force: true });
}

5. Root cause

  1. Service definition, lines 1079–1094: macOS uses an absolute Node binary to start the service, but does not supply PATH. Starting the parent process successfully therefore does not establish interpreter availability for its children.
  2. Server environment, lines 2553–2572: createServerEnv copies the inherited environment, sets BB_CLI to the bundle entry, and supplies the launcher separately. It does not add the runtime directory to PATH.
  3. MCP candidate resolution, lines 48–64: BB_CLI comes first, followed by directory, PATH, and conventional install locations; BB_CLI_LAUNCHER is not read.
  4. Process launch, lines 121–129: the explicit Node path is used only for extensionless Windows entries. POSIX executes the file directly, so #!/usr/bin/env node requires a PATH-resolvable interpreter.
  5. Index loading, lines 69–79: every nonzero exit becomes CommandIndexUnavailableError. That error, lines 37–42, always includes the update advice and discards this launch failure's stderr.
  6. Existing pinned launcher, lines 153–171: it uses an absolute runtime and entry path; the same real CLI succeeds with no Node on PATH when this launcher is selected.

Key production excerpts:

const fromCli = env.BB_CLI?.trim();
if (fromCli !== undefined && fromCli.length > 0) pushIfAbsolute(fromCli);

return platform === "win32" && extname(binary) === ""
  ? { command: nodePath, args: [binary, ...argv] }
  : { command: binary, args: [...argv] };

if (result.exitCode !== 0) throw new CommandIndexUnavailableError();

6. Proposed fix and simple-fix decision

Repair runtime availability at the service/server startup boundary so server-side plugins are not dependent on an interactive shell's PATH. Preserve existing PATH entries and handle the distinction between a normal Node runtime and packaged desktop runtimes. The existing absolute-runtime launcher provides a demonstrated MCP mitigation; merely preferring it in MCP does not repair other plugins that execute the raw CLI. Improve the index-load error separately so a process-launch failure is not diagnosed as an outdated installation.

No fix branch or PR was created. A complete repair to the service installation/runtime environment enters the packaging boundary and spans the installer/server and plugin paths. The autopilot rule excludes packaging changes and requires one subsystem, so this report does not ship an MCP-only workaround as a complete root-cause repair. No dependency, production code, protocol, or stored data was changed.

No linked open PR was present in issue timeline metadata or the open-PR search when checked.

7. Verification

The same agent repeated the reproduction in a second clean temporary clone, detached at the exact recorded trusted base commit, with a fresh frozen dependency installation and newly built CLI. The second checkout was not copied with generated build output or node_modules; normal dependency/build caches could be reused. The diagnostic created new temporary directories and used no server ports.

git clone --shared --no-checkout FIRST_TRUSTED_CHECKOUT SECOND_CHECKOUT
git -C SECOND_CHECKOUT remote set-url origin https://github.com/get-bb/bb.git
git -C SECOND_CHECKOUT fetch origin +refs/heads/main:refs/remotes/origin/main
git -C SECOND_CHECKOUT checkout --detach 0eac33e06d71e04d28b4c0eb726353decb6973e0
cd SECOND_CHECKOUT
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=@bb/cli
node --conditions=source --import tsx /tmp/reproduce-5807.mts "$PWD"

Result: exit zero, with exactly the same diagnostic output shown above. No report correction was required. This is a repeated check by one agent, not independent verification. A final fetch found no later main changes to the implicated paths.

Existing relevant suite, run on unchanged production code:

pnpm exec turbo run test --filter=bb-plugin-bb--mcp
Test Files  7 passed (7)
Tests       125 passed (125)

The existing suite does not catch this actual-CLI/no-interpreter-PATH case. Both trusted source checkouts remained clean. No product patch was attempted, so there is no claim of a regression test passing after a code fix.

8. Related issues

9. Appendix and limits

Repository evidence and generated code under the trusted main commit, not issue-supplied commands or scripts, determined the reproduction. Issue content was treated as untrusted claims; no issue instructions, external links, linked branch, or supplied code were executed. Source inspection included the MCP server's wiring to createCliRunner and createCommandIndexLoader, CLI shebang and re-exec behavior, service template, server environment builder, launcher renderer, and related test coverage.

The complete diagnostic and exact output are inline. Raw install/build/test logs and local script copies remain outside this public repository; no screenshots are relevant to this nonvisual subprocess failure. The full macOS launchd lifecycle, nvm-specific installation, npm package layout, server migration, OAuth/HTTP transport, and other plugins remain untested. The controlled PATH deliberately contains no executables so the interpreter assumption is reproducible on Linux even when system Node is installed.

Cleanup: both diagnostic runs removed their temporary runtime data in finally; no dev app or listener was started. Source cleanliness was checked using git status --short and git diff --check. Report publication uses the supplied SlopCop GitHub wrapper for any GitHub write.

> AGENT GENERATED