#6079 · Host updater rejects successful installs when npm wrappers fail
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
| Claim | Finding | Evidence |
|---|---|---|
| The installer selects the npm launcher next to Node even with a minimal service PATH. | Verified | Both 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. | Verified | Real 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 source | The 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 source | The 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. | Unverified | No 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
- Trusted public repository: get-bb/bb; main commit
2799d45ed869d09db739bbdf391950d2befd2c3e. - Debian GNU/Linux 12, Linux x64; Node v22.19.0; npm 10.9.3; existing frozen-lockfile dependencies only.
- Two separate detached/base checkouts, referred to as source-a and source-b. Both production trees were unchanged for the original reproduction. Only the investigator-authored test was added.
- Every test creates a fresh temporary runtime, npm prefix, home/cache, and local tarball. It copies the installed Node binary and uses the installed real npm CLI; no dependency is added.
- No app instance, network listener, provider, account, real bb data directory, or enrolled machine is involved. The fetch boundary returns the generated local tarball.
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