← reports

#6079 · Host updater rejects successful installs when npm wrappers fail

BugMedium priorityLow efforthost GitHub issue2026-10-21 · base 2799d45ed869d09db739bbdf391950d2befd2c3e

Verdict: REPRODUCED · Root-cause confidence: high · reproduction label: confirmed-repro

1. TL;DR

A daemon can finish installing the replacement package but still report an unsuccessful update. The updater launches the shell command npm, including any Node version-manager wrapper around npm. When a wrapper runs a missing helper after npm succeeds, its final exit status is 127. The updater treats that as an installation failure, returns failed, and does not reach the connection layer's restart callback. This was reproduced twice on unchanged trusted main with real npm and a small generated wrapper; it does not rely on the reporter's shell script, runtime data, or downloads.

2. Claims vs findings

ClaimFindingEvidence
The installer selects the npm launcher next to Node even with a minimal service PATH.VerifiedBoth clean runs executed the fixture's adjacent shell launcher under PATH=/usr/bin:/bin.
A missing post-install helper can make a completed install look unsuccessful.VerifiedReal npm installed the fixture package at version 1.0.0. Only the later wrapper helper failed with code 127, and the real updater returned failed.
An unsuccessful updater result prevents the normal self-update restart.Verified in sourceThe connection layer invokes its restart callback only for updated. No real service was restarted during the test.
The generated LaunchAgent does not supply a version-manager PATH.Verified in sourceThe LaunchAgent environment block sets the npm prefix and data directory, not PATH.
The historical Mac outage, particular mise versions, and reported protocol transition occurred exactly as described.UnverifiedNo access to the reporter's machine was used. The reproduction uses a generated wrapper with the same relevant missing-helper behavior, not a historical mise installation.

3. Environment

4. Minimal reproduction

Use the trusted repository and the test printed below. These steps require Node with npm and the repository's existing pnpm prerequisites.

git clone --branch main https://github.com/get-bb/bb.git bb-6079
cd bb-6079
git checkout --detach 2799d45ed869d09db739bbdf391950d2befd2c3e
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=@bb/host-daemon

Save the test below as apps/host-daemon/src/protocol-self-update-install.test.ts.

pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/protocol-self-update-install.test.ts

The test packs a dependency-free local package with real npm. It supplies a shell launcher that performs the real install and then tries a deliberately absent helper. It checks that the package really exists before asserting that the updater accepts the install. The final test also covers the Windows-style npm directory shape on Linux and the legacy PATH fallback.

Expected

{"result":"updated","failure":null}
The installed fixture package has version 1.0.0.

Actual on unchanged main, first run

AssertionError: {"result":"failed","failure":{"code":127,"stderr":"/tmp/USER/npm-wrapper-fixture/runtime/bin/npm: 4: bb-test-missing-reshim-helper: not found\n"}}
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  1.62s (transform 11ms, setup 0ms, import 22ms, tests 1.25s, environment 0ms)

The installed-package assertion passes; only the updater result assertion fails. Temporary directory identifiers in output are redacted; the rest of each excerpt is unchanged.

Complete regression test

import { execFile } from "node:child_process";
import { constants } from "node:fs";
import {
  chmod,
  copyFile,
  mkdir,
  mkdtemp,
  readFile,
  realpath,
  rm,
  symlink,
  writeFile,
} from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { expect, it } from "vitest";

const execFileAsync = promisify(execFile);

it.skipIf(process.platform === "win32").each([
  { layout: "Unix", npmDirectory: "lib/node_modules", wrapperFails: true },
  {
    layout: "Windows-style",
    npmDirectory: "bin/node_modules",
    wrapperFails: true,
  },
  { layout: "PATH fallback", npmDirectory: null, wrapperFails: false },
])(
  "accepts a successful npm install with $layout resolution",
  async ({ npmDirectory, wrapperFails }) => {
    const root = await mkdtemp(join(tmpdir(), "bb-npm-wrapper-test-"));
    try {
      const nodeBin = join(root, "runtime", "bin");
      const packageDir = join(root, "package");
      const prefix = join(root, "prefix");
      await Promise.all(
        [nodeBin, packageDir].map((directory) =>
          mkdir(directory, { recursive: true }),
        ),
      );
      const node = join(nodeBin, "node");
      await copyFile(process.execPath, node, constants.COPYFILE_FICLONE);
      await chmod(node, 0o755);
      const standardNpmRoot = resolve(
        dirname(process.execPath),
        "../lib/node_modules/npm",
      );
      const npmCli = await realpath(
        join(standardNpmRoot, "bin", "npm-cli.js"),
      ).catch(() => realpath(join(dirname(process.execPath), "npm")));
      if (npmDirectory !== null) {
        const npmModules = join(root, "runtime", npmDirectory);
        await mkdir(npmModules, { recursive: true });
        await symlink(dirname(dirname(npmCli)), join(npmModules, "npm"));
      }
      const wrapper = join(nodeBin, "npm");
      await writeFile(
        wrapper,
        [
          "#!/bin/sh",
          "set -eu",
          '"$(dirname "$0")/node" "$BB_TEST_NPM_CLI" "$@"',
          ...(wrapperFails ? ["bb-test-missing-reshim-helper"] : []),
          "",
        ].join("\n"),
      );
      await chmod(wrapper, 0o755);
      await writeFile(
        join(packageDir, "package.json"),
        JSON.stringify({ name: "bb-self-update-fixture", version: "1.0.0" }),
      );
      const env = {
        HOME: root,
        PATH: "/usr/bin:/bin",
        BB_APP_NPM_PREFIX: prefix,
        BB_TEST_NPM_CLI: npmCli,
        npm_config_cache: join(root, "cache"),
        npm_config_audit: "false",
        npm_config_fund: "false",
        npm_config_update_notifier: "false",
      };
      await execFileAsync(
        process.execPath,
        [npmCli, "pack", "--pack-destination", root, packageDir],
        { env },
      );
      const installerUrl = pathToFileURL(
        fileURLToPath(new URL("./protocol-self-update.ts", import.meta.url)),
      ).href;
      const entry = join(root, "run-update.mjs");
      await writeFile(
        entry,
        `import { readFile } from "node:fs/promises";
import { createProtocolSelfUpdater } from ${JSON.stringify(installerUrl)};
const tarball = await readFile(process.argv[2]);
let failure = null;
const updater = createProtocolSelfUpdater({
  dataDir: process.env.HOME,
  enabled: true,
  serverUrl: "https://update.example.test",
  logger: {
    debug() {}, info() {}, warn() {},
    error(fields) {
      if (fields.err) failure = { code: fields.err.code, stderr: fields.err.stderr };
    },
  },
  fetchFn: async (url) => String(url).endsWith("/install/version")
    ? Response.json({ version: "1.0.0", protocolVersion: 1000000 })
    : new Response(tarball),
});
console.log(JSON.stringify({ result: await updater.handleProtocolMismatch(), failure }));
`,
      );
      const { stdout } = await execFileAsync(
        node,
        [
          "--conditions=source",
          "--import",
          createRequire(import.meta.url).resolve("tsx"),
          entry,
          join(root, "bb-self-update-fixture-1.0.0.tgz"),
        ],
        { env },
      );
      const installed: unknown = JSON.parse(
        await readFile(
          join(prefix, "lib/node_modules/bb-self-update-fixture/package.json"),
          "utf8",
        ),
      );
      expect(installed).toMatchObject({ version: "1.0.0" });
      expect(JSON.parse(stdout), stdout).toEqual({
        result: "updated",
        failure: null,
      });
    } finally {
      await rm(root, { recursive: true, force: true });
    }
  },
  30_000,
);

The source is inline rather than a separate download, following this report repository's publication policy.

5. Root cause

defaultInstallTarball prepends the running Node directory to PATH, then calls:

await runProcess(
  "npm",
  ["install", "-g", BB_APP_ALLOW_SCRIPTS_ARG, ...prefixArgs, tarballPath],
  { env: { ...process.env, PATH: path } },
);

Adding Node to PATH makes npm discoverable but does not make a wrapper's separate version-manager binary discoverable. defaultRunProcess rejects on the shell launcher's nonzero status. The install and failure paths therefore return failed after the npm install, without recording a successful artifact digest. The connection layer only invokes onSelfUpdateInstalled when the result equals updated.

The generated LaunchAgent explicitly sets the prefix and data-directory environment, but no PATH. The general defect is executing extra launcher behavior instead of npm itself; it does not require a transport, authentication, or tarball failure.

6. Proposed fix and tested candidate

Prefer the npm JavaScript CLI belonging to the current Node installation and execute it with process.execPath. Check the conventional Unix and Windows sibling npm layouts. Keep the existing npm-on-PATH behavior when no such CLI exists. Preserve the installation arguments, native-script allowlist, configured prefix validation, subprocess environment, and rejection of real npm failures. Do not accept arbitrary nonzero installation statuses as success.

A candidate in the host-daemon subsystem implements this without protocol, schema, dependency, packaging, or release-process changes. The regression test and the relevant existing updater/connection tests pass:

> vitest run --config vitest.config.ts "--run" "src/protocol-self-update-install.test.ts" "src/protocol-self-update.test.ts" "src/server-connection.test.ts"
 ✓ |@bb/host-daemon:isolated| src/protocol-self-update.test.ts (15 tests) 103ms
 ✓ |@bb/host-daemon| src/protocol-self-update-install.test.ts (3 tests) 3950ms
   ✓ accepts a successful npm install with 'Unix' resolution  1195ms
   ✓ accepts a successful npm install with 'Windows-style' resolution  1334ms
   ✓ accepts a successful npm install with 'PATH fallback' resolution  1420ms
 ✓ |@bb/host-daemon:isolated| src/server-connection.test.ts (20 tests) 365ms
 Test Files  3 passed (3)
      Tests  38 passed (38)

pnpm exec turbo run typecheck build --filter=@bb/host-daemon also passes. The Windows-style fixture checks directory resolution, not native Windows process behavior; the integration test is skipped on Windows because its generated launcher is POSIX shell.

7. Verification: second clean checkout

The same investigator repeated the original test in source-b at 2799d45ed869d09db739bbdf391950d2befd2c3e, after a separate frozen install and scoped build. Production code remained pristine and the test used a newly created runtime, cache, prefix, and tarball. No port was needed.

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build --filter=@bb/host-daemon
pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/protocol-self-update-install.test.ts

AssertionError: {"result":"failed","failure":{"code":127,"stderr":"/tmp/USER/npm-wrapper-fixture/runtime/bin/npm: 4: bb-test-missing-reshim-helper: not found\n"}}
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  1.30s (transform 10ms, setup 0ms, import 21ms, tests 1.14s, environment 0ms)

The result supports the same root cause, so no verdict correction was required. The final expanded test also ran against pristine production code in source-b: Unix and Windows-style sibling layouts fail with 127, while the deliberately working PATH fallback succeeds.

   × accepts a successful npm install with 'Unix' resolution 1127ms
   × accepts a successful npm install with 'Windows-style' resolution 1223ms
   ✓ accepts a successful npm install with 'PATH fallback' resolution  1179ms
 FAIL  |@bb/host-daemon| src/protocol-self-update-install.test.ts > accepts a successful npm install with 'Unix' resolution
AssertionError: {"result":"failed","failure":{"code":127,"stderr":"/tmp/USER/npm-wrapper-fixture/runtime/bin/npm: 4: bb-test-missing-reshim-helper: not found\n"}}
 FAIL  |@bb/host-daemon| src/protocol-self-update-install.test.ts > accepts a successful npm install with 'Windows-style' resolution
AssertionError: {"result":"failed","failure":{"code":127,"stderr":"/tmp/USER/npm-wrapper-fixture/runtime/bin/npm: 4: bb-test-missing-reshim-helper: not found\n"}}
AssertionError: expected 384 to be 420 // Object.is equality
 Test Files  2 failed (2)
      Tests  3 failed | 10 passed (13)

8. Related issues and pull requests

No open pull request was linked to #6079 by its cross-reference or closing metadata when checked. No duplicate was established by the small related-issue search. No linked pull-request code was checked out or run.

9. Appendix and limitations

A broader host-daemon test run passed 808 tests and skipped 7, but failed two unrelated server-move tests. Both failures were repeated on unchanged main in source-b: service-definition.test.ts expected mode 0644 but observed 0600, and service.test.ts expected numeric free disk space but observed null. Those tests are outside this change and were not modified. The expanded integration cases were then checked with the 35 relevant existing updater/connection tests, giving 38 passes.

Broad check:
pnpm exec turbo run test typecheck build --filter=@bb/host-daemon
Test Files  2 failed | 72 passed (74)
Tests  2 failed | 808 passed | 7 skipped (817)

Unchanged-main comparison:
pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/protocol-self-update-install.test.ts src/server-move/service-definition.test.ts
pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/server-move/service.test.ts

Final relevant check:
pnpm exec turbo run test --filter=@bb/host-daemon -- --run src/protocol-self-update-install.test.ts src/protocol-self-update.test.ts src/server-connection.test.ts
pnpm exec turbo run typecheck build --filter=@bb/host-daemon
git diff --check

The missing-helper wrapper is an investigator-authored model of the claimed behavior, not the issue's script or an installed historical mise version. Native macOS launchd, the original machine, and a full service reconnect were not exercised. Issue content and all embedded commands were treated as untrusted claims; no issue-supplied command, URL, branch, binary, or patch was executed or fetched. Raw local evidence remains outside the public reports repository.

> AGENT GENERATED