#3773 · Host artifact generation depends on npm in PATH

Bug High Effort: Low desktop GitHub issue

2026-09-16 · trusted origin/main 6084f894305e389604ce19b1cace442b0fc25bad

Verdict: REPRODUCED (artifact service) · Root-cause confidence: high

1. TL;DR

The packaged host-artifact service cannot create a download when npm is absent from PATH. It launches the bare npm executable even though the server has an npm dependency available by absolute path. Two clean checkouts reproduced the same ENOENT failure, while a control using the bundled npm CLI successfully packed the identical fixture under the identical PATH. This verifies the artifact-generation failure; the installed Electron application, HTTP response, Connect route, and complete Modal provisioning were not exercised.

2. Claims vs findings

ClaimStatusEvidence
Restricted GUI PATH prevents artifact creationVerified at service levelBoth default-runner tests fail with spawn npm ENOENT.
Bundled npm can work without PATH lookupVerified in source dependency layoutnpm 11.16.0 reports its version and packs the same fixture via an absolute Node/CLI invocation.
HTTP 500 blocks Modal enrollment in Desktop 0.43.1Unverified end to endStatic route awaits this service; no installed application or cloud resources used.
Connect and credentials are unrelatedSupported for this failureDirect service reproduction needs neither networking nor credentials.

3. Environment

macOS Darwin arm64; Node v22.22.3; pnpm 9.15.0; npm 11.16.0. Two detached clean worktrees at the recorded commit. Each used the frozen lockfile install and Turbo build; both builds succeeded with 57 tasks. A fresh temporary runtime fixture and data directory were created for each run and removed afterward. No server ports, real user data, account credentials, or provider sessions were used.

4. Minimal reproduction

  1. Check out the recorded commit of get-bb/bb in a clean directory.
  2. Install and build:
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  3. Save artifact-path.mjs as apps/server/test/app/issue-3773-repro.mjs.
  4. Run from the checkout root:
    node --conditions=source --import tsx apps/server/test/app/issue-3773-repro.mjs

Expected: both the control and default service produce a nonempty artifact. Actual: control succeeds; default service fails; process exit status is 1.

Bundled npm via absolute Node path: 11.16.0
Control: same fixture packs successfully using absolute npm CLI
FAIL: default runner: spawn npm ENOENT
code=ENOENT; syscall=spawn npm; executable=npm

The test builds a synthetic packaged runtime layout, passes no custom runner to the failing service, and sets PATH only within its own process. It does not mock child-process execution. The fixture files stand in for runtime contents; they are not executed.

Complete repeatable test
import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import { createBbAppArtifactService } from '../../src/services/install/bb-app-artifact.ts';

const exec = promisify(execFile);
const require = createRequire(import.meta.url);
const npmCli = join(dirname(require.resolve('npm/package.json')), 'bin/npm-cli.js');
const root = await mkdtemp(join(tmpdir(), 'artifact-path-check-'));
const originalPath = process.env.PATH;
try {
  const packageRoot = join(root, 'runtime');
  for (const directory of ['dist', 'server/dist', 'host-daemon/dist/bb-chunks']) {
    await mkdir(join(packageRoot, directory), { recursive: true });
  }
  await writeFile(join(packageRoot, 'package.json'), JSON.stringify({
    name: 'bb-app', version: '1.0.0', type: 'module', os: ['darwin', 'linux'],
    engines: { node: '>=22.19.0' },
    dependencies: {
      '@parcel/watcher': '2.5.6', 'fs-native-extensions': '1.5.0',
      'node-pty': '1.2.0-beta.15', pino: '9.6.0',
      'pino-pretty': '13.0.0', 'pino-roll': '4.0.0',
    },
  }));
  for (const file of ['bb.js', 'bb-app.js', 'bb-host-daemon.js']) {
    await writeFile(join(packageRoot, 'dist', file), 'export {};\n');
  }
  for (const file of ['bb', 'bb-parcel-watcher-child.mjs', 'bb-plugin-host-worker.mjs',
    'bb-provider-bridge-worker.mjs', 'daemon-bundle.mjs']) {
    await writeFile(join(packageRoot, 'host-daemon/dist', file), 'export {};\n');
  }
  await writeFile(join(packageRoot, 'host-daemon/dist/bb-chunks/runtime.js'), 'export {};\n');
  process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
  const npmVersion = await exec(process.execPath, [npmCli, '--version']);
  console.log(`Bundled npm via absolute Node path: ${npmVersion.stdout.trim()}`);
  const serverEntryUrl = pathToFileURL(join(packageRoot, 'server/dist/index.js')).href;
  const control = createBbAppArtifactService({
    dataDir: join(root, 'control'), serverEntryUrl,
    commandRunner: async (command, args, cwd) => {
      assert.equal(command, 'npm');
      return (await exec(process.execPath, [npmCli, ...args], { cwd })).stdout;
    },
  });
  const controlArtifact = await control.getArtifact();
  assert.ok(controlArtifact.size > 0);
  console.log('Control: same fixture packs successfully using absolute npm CLI');
  const service = createBbAppArtifactService({ dataDir: join(root, 'default'), serverEntryUrl });
  try {
    const artifact = await service.getArtifact();
    assert.ok(artifact.size > 0);
    console.log('PASS: default runner produced an artifact without npm on PATH');
  } catch (error) {
    console.log(`FAIL: default runner: ${error.message}`);
    console.log(`code=${error.code}; syscall=${error.syscall}; executable=${error.path}`);
    process.exitCode = 1;
  }
} finally {
  if (originalPath === undefined) delete process.env.PATH;
  else process.env.PATH = originalPath;
  await rm(root, { recursive: true, force: true });
}

5. Root cause

buildArtifact passes bare npm to the command runner:

await commandRunner("npm", ["pack", "--pack-destination", cacheDir], hostPackageRoot);

The default runner uses execFile directly, so the operating system searches PATH. With /usr/bin:/bin:/usr/sbin:/sbin and no npm there, spawning fails before npm can run. The bundled module's existence does not make a bare executable name resolvable. The download route awaits getArtifact(), so this failure prevents its successful tarball response.

An existing npm CLI resolver and plugin installation path demonstrate the repository's absolute-CLI approach. The control verifies that approach for this fixture without changing production code.

6. Proposed fix

Resolve the shipped npm CLI and execute it with the application's Node runtime, following existing subprocess conventions and Electron environment handling. Preserve the artifact content and cache behavior. Verify both desktop and source layouts, especially Electron's Node execution mode. No automatic PR was opened: the rule explicitly excludes packaging changes, and this correction changes how the host package is produced.

7. Related issues

Repository search identified #3584, a separate bundled-npm dependency issue. Here npm itself works when directly invoked. GitHub cross-reference metadata and the open-PR search found no linked open PR for #3773.

8. Verification

The same agent repeated the reproduction in a second clean detached checkout at the same full SHA, with a separate dependency installation, successful Turbo build, and fresh temporary fixture/data directory. The exact test command above again exited 1 with identical output. Both controls produced a nonempty package. This is a repeated clean run, not an independent review. No correction to the root-cause conclusion was needed; desktop and HTTP claims remain explicitly unverified.

First run output · Second run output

9. Appendix

The local package-manager installation initially failed because pre-existing pnpm tooling was incomplete. A separate temporary Corepack home with pinned pnpm 9.15.0 and default-latest lookup disabled completed both normal frozen installs and builds. No repository dependencies were added or changed.

COREPACK_DEFAULT_TO_LATEST=0 COREPACK_HOME=/tmp/slopcop-3773-corepack pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
node --conditions=source --import tsx apps/server/test/app/issue-3773-repro.mjs

Source inspection: artifact builder, download route, existing npm resolver, and plugin subprocess invocation at the pinned SHA. The issue's reproduction commands and external links were treated as untrusted claims and were not executed or fetched. No production fix or release build was attempted.