← reports

#1558 · bb-app can enroll a host against the wrong server after its server child fails with EADDRINUSE

Bug Medium Effort: Small desktop connect open on GitHub 2026-08-18 base 16ceb3a540f81c1189efaffb27a39b1d9443abf5 (main)

Verdict: REPRODUCED · root-cause confidence: high · linked open PRs: none

TL;DR

Plain-language framing. npx bb-app (the "launcher") starts two child processes: the bb server (HTTP API + SQLite database under BB_DATA_DIR) and the host daemon (the process that runs agents on this machine). On first start the daemon has no credentials, so the launcher asks the server for a one-time enroll key, the daemon exchanges it for a permanent host key, and the daemon writes that key to <BB_DATA_DIR>/auth.json. The server remembers the enrolled host in its own bb.db. From then on the daemon authenticates with that host key.

The launcher decides that its server child is "up" by polling GET http://127.0.0.1:<port>/health until it gets a 200. It never checks who answered. If some other bb server already owns the port (for example a second bb-app run with a different BB_DATA_DIR but default ports), the very first poll succeeds against that foreign server, long before the launcher's own child has finished booting and died with EADDRINUSE. The launcher prints ✓ Server listening, mints an enroll key from the foreign server, and its daemon enrolls there. The daemon then persists the resulting host key into the second data directory. Later, when the first bb is gone and the second one starts on its own database, the daemon presents a host key that only exists in the other database and is rejected with 401 Unauthorized (server log: Better Auth INVALID_API_KEY; daemon log: "Server rejected host credentials — this host is not registered with the server"). The second data dir is now permanently broken until auth.json is deleted.

Reproduced end to end on 16ceb3a54 with the built packages/bb-app/dist/bb-app.js (identical launcher code to 0.35.1 and 0.38.0), on non-default ports so the user's real instance was not touched. It is not a narrow race: the foreign 200 wins on the first 100 ms poll every time because a bb server takes seconds to boot. A one-file vitest repro at the exact function (waitForHealth) fails on main. In a variant where only the server port collides, the launcher even reports bb is ready and then loops ✓ Server restarted forever while its own server dies with EADDRINUSE each time.

Claims vs findings

ClaimStatusEvidence
Second launcher (different data dir, same ports) can mistake the first launcher's server for its ownVerifiedRepro step 2: launcher two prints ✓ Server listening on http://127.0.0.1:45886 while its own server child prints listen EADDRINUSE a moment later (launcher-two-first-run.log).
Launcher requests an enroll key from the existing server and saves host credentials in the second data dirVerified/tmp/bb1558-two/auth.json and host-id = host_jqdf8jbftp; sqlite3 /tmp/bb1558-one/bb.db "select id from hosts" lists it, /tmp/bb1558-two/bb.db has an empty hosts table.
After the first launcher stops, the second one fails with "Server rejected host credentials — this host is not registered with the server" / INVALID_API_KEYVerifiedRepro step 3 (launcher-two-restart.log): both strings appear verbatim; INVALID_API_KEY comes from the Better Auth apikey plugin log inside the server, the other from logFatalConnectError in the daemon.
Host ID from bb-two/auth.json present in bb-one/bb.db, absent from bb-two/bb.dbVerifiedSee above.
Suspected cause: race in waitForHealth() between the child's exit and the unrelated server's 200Verified, and stronger than a racelauncher.ts#L2164-L2186: the loop checks exitCode then fetches. The first fetch happens ~0 ms after spawn; the child needs seconds to reach listen(). So the foreign 200 is accepted deterministically, not occasionally. Unit repro fails 1/1 runs.
"A 2xx only proves that a server is listening, not that it is the child just spawned"Verified/health returns a constant {"ok":true} (server.ts#L331). The launcher already does an identity check for the daemon (waitForHostDaemonStatus compares hostId and serverUrl, added by #1155) but not for the server.
Environment: bb-app 0.35.1ConsistentwaitForHealth at tag desktop-v0.35.1 is byte-identical to 16ceb3a54.
Launcher "may still consider startup successful"VerifiedVariant with a distinct daemon port: launcher two prints ● bb is ready, then loops ! server exited with code 1 - restarting server / ✓ Server restarted (log). With the daemon port also colliding, the daemon fails its readiness check after ~60 s and the launcher exits 1 — but auth.json is already written.
Error-message suggestion (INVALID_API_KEY is misleading; unrecoverable auth failures should stop the restart loop)Opinion, partially mootThe daemon already treats 401/403 as fatal (fatalConnectError, server-connection.ts#L402-L432) and exits with reason: startup-failed; the launcher then exits 1. The INVALID_API_KEY line is a Better Auth internal log, not a bb message. Adding the "remove auth.json" hint to the daemon's fatal message would be cheap.

Environment

Minimal reproduction

Scripts are in 1558/repro/. They hardcode the launcher path of my worktree in BBAPP=; point it at your own packages/bb-app/dist/bb-app.js (or replace node "$BBAPP" with npx bb-app@0.38.0, which reproduces identically with default ports if nothing else is on 38886).

  1. Start instance one and wait for bb is ready (start-one.sh; output launcher-one.log):
    $ bash 1558/repro/start-one.sh          # BB_DATA_DIR=/tmp/bb1558-one BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 node dist/bb-app.js
    $ grep -A8 "bb is ready" /tmp/bb1558-one/launcher.log
      ●  bb is ready
         app    http://127.0.0.1:45886
         daemon 45887
         data   /tmp/bb1558-one
         db     /tmp/bb1558-one/bb.db
  2. Start instance two with a different data dir and the same ports (start-two.sh). Expected (issue's "Expected behavior"): the launcher fails startup because its server child cannot bind the port, and does not create host authentication state. Actual (launcher-two-first-run.log, verbatim):
    $ bash 1558/repro/start-two.sh          # BB_DATA_DIR=/tmp/bb1558-two, same ports
    $ cat /tmp/bb1558-two/launcher.log
    
      bb
    
      ○  Starting server
      ✓  Server listening on http://127.0.0.1:45886
      ○  Starting host daemon
    node:events:487
          throw er; // Unhandled 'error' event
          ^
    
    Error: listen EADDRINUSE: address already in use 127.0.0.1:45886
        at Server.setupListenHandle [as _listen2] (node:net:2009:16)
        at listenInCluster (node:net:2066:12)
        at node:net:2275:7
        at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
    Emitted 'error' event on Server instance at:
        at emitErrorNT (node:net:2045:8)
        at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
      code: 'EADDRINUSE',
      errno: -98,
      syscall: 'listen',
      address: '127.0.0.1',
      port: 45886
    }
    
    Node.js v24.18.0
    Error: Host daemon local API port 45887 is already in use on 127.0.0.1. Choose another port with --host-daemon-port <port>.
        at Cqr (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:720:21269)
        at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
        at async v$r (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:743:30124)
        at async Object.qUo (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:752:3705)
        at async QUo (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:753:62)
      ✗  Host daemon failed to start
         lock: /tmp/bb1558-two/daemon.lock.lock
         logs: /tmp/bb1558-two/logs/
    
    [07:40:07] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
    
      ●  Shutting down
    
    Note the order: ✓ Server listening is printed first (answered by instance one), then the launcher moves on to Starting host daemon, and only afterwards does its own server child die with EADDRINUSE. The daemon then fails on its own port collision, but not before enrolling:
    $ ls /tmp/bb1558-two
    auth-secret  auth.json  bb-app-runtime.json  bb.db  bb.db-shm  bb.db-wal  daemon.lock  host-id  launcher.log  logs  skills  telemetry-id  thread-storage
    $ cat /tmp/bb1558-two/auth.json
    {
      "hostId": "host_jqdf8jbftp",
      "hostKey": "bbdh_<redacted>",
      "hostType": "persistent"
    }
    $ sqlite3 /tmp/bb1558-one/bb.db "select id, name from hosts"
    host_yej96dezaf|bee
    host_jqdf8jbftp|bee                    <-- instance two's host, enrolled in instance ONE's database
    $ sqlite3 /tmp/bb1558-two/bb.db "select id, name from hosts"
                                           <-- empty
  3. Stop instance one, start instance two again on its (now free) ports, keeping its data dir (restart-two.sh). Expected: instance two starts. Actual (launcher-two-restart.log):
    $ kill <launcher one pid>; bash 1558/repro/restart-two.sh
    $ cat /tmp/bb1558-two/launcher-restart.log
    
      bb
    
      ○  Starting server
      ✓  Server listening on http://127.0.0.1:45886
      ○  Starting host daemon
    2026-08-18T07:41:47.238Z ERROR [Better Auth]: Failed to validate API key: [APIError6: Invalid API key.] {
      status: 'UNAUTHORIZED',
      body: { message: 'Invalid API key.', code: 'INVALID_API_KEY' },
      headers: {},
      statusCode: 401
    }
    ServerResponseError: Failed to open session: 401 Unauthorized - Unauthorized
        at i (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:701:35325)
        at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
        at async Object.openSession (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:701:36219)
        at async wle.openSession (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:742:8095)
        at async n.createWebSocket.minReconnectionDelay (file:///home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/host-daemon/dist/daemon-bundle.mjs:742:9464)
        at async Promise.all (index 0)
      ✗  Host daemon failed to start
         lock: /tmp/bb1558-two/daemon.lock.lock
         logs: /tmp/bb1558-two/logs/
    
    [07:41:46] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
    [07:41:46] INFO: [server] plugin automations@0.1.0 loaded
    [07:41:46] INFO: [server] plugin connect@0.1.0 loaded
    [07:41:46] INFO: [server] plugin custom-instructions@0.1.0 loaded
    [07:41:46] INFO: [server] plugin inline-vis@0.1.0 loaded
    [07:41:46] INFO: [server] plugin keep-awake@0.1.0 loaded
    [07:41:46] INFO: [server] plugin provider-acp@0.1.0 loaded
    [07:41:46] INFO: [server] plugin provider-claude-code@0.1.0 loaded
    [07:41:46] INFO: [server] plugin provider-codex@0.1.0 loaded
    [07:41:46] INFO: [server] plugin provider-pi@0.1.0 loaded
    [07:41:46] INFO: [server] plugin secrets@0.1.0 loaded
    [07:41:46] INFO: [server] plugin side-chat@0.1.0 loaded
    [07:41:47] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-two"}
    [07:41:47] ERROR: [host-daemon] Server rejected host credentials — this host is not registered with the server. {"serverUrl":"http://127.0.0.1:45886","hostId":"host_jqdf8jbftp","status":401,"code":"unauthorized","detail":"Unauthorized"}
    [07:41:47] INFO: [host-daemon] Shutting down host daemon {"serverUrl":"http://127.0.0.1:45886","mode":"shutdown","reason":"startup-failed"}
    
      ●  Shutting down
    
    Instance two is now unusable until auth.json (and host-id) are removed, exactly as the issue's workaround says.

Variant: only the server port collides → "bb is ready" and an endless restart loop

start-two-daemon-port-differs.sh uses BB_HOST_DAEMON_PORT=45889 for instance two. Its daemon now binds fine, enrolls with instance one, connects to instance one, and passes the launcher's daemon identity check (the expected host id came from instance one's enroll-key response). The launcher declares victory and then restarts its doomed server child every ~2 s, each time "successfully" (full log):

  bb

  ○  Starting server
  ✓  Server listening on http://127.0.0.1:45886
  ○  Starting host daemon
node:events:487
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE: address already in use 127.0.0.1:45886
    at Server.setupListenHandle [as _listen2] (node:net:2009:16)
    at listenInCluster (node:net:2066:12)
    at node:net:2275:7
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Emitted 'error' event on Server instance at:
    at emitErrorNT (node:net:2045:8)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  code: 'EADDRINUSE',
  errno: -98,
  syscall: 'listen',
  address: '127.0.0.1',
  port: 45886
}

Node.js v24.18.0
  ✓  Host daemon running

  ●  bb is ready

     app    http://127.0.0.1:45886
     daemon 45889
     data   /tmp/bb1558-two
     db     /tmp/bb1558-two/bb.db
     logs   /tmp/bb1558-two/logs/
     lock   /tmp/bb1558-two/daemon.lock

     Press Ctrl+C to stop

[07:44:11] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
  !  server exited with code 1 - restarting server
[07:44:11] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-two"}
[07:44:11] INFO: [host-daemon] Connected to server {"serverUrl":"http://127.0.0.1:45886","sessionId":"hses_3yj4izin5i"}
[07:44:11] INFO: [host-daemon] Host daemon started {"serverUrl":"http://127.0.0.1:45886","identity":{"hostId":"host_2t69e294wf","hostName":"bee","instanceId":"457dd088-30ee-45de-bf1d-a05653bdd965"}}
[07:44:11] INFO: [host-daemon] Host plugin worker ready {"serverUrl":"http://127.0.0.1:45886","pluginId":"keep-awake","startupDurationMs":41}
  ○  Restarting server
  ✓  Server restarted
[07:44:13] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-two"}
node:events:487
      throw er; // Unhandled 'error' event
      ^

Error: listen EADDRINUSE: address already in use 127.0.0.1:45886
    at Server.setupListenHandle [as _listen2] (node:net:2009:16)
    at listenInCluster (node:net:2066:12)
    at node:net:2275:7
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21)
Emitted 'error' event on Server instance at:
    at emitErrorNT (node:net:2045:8)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  code: 'EADDRINUSE',
  errno: -98,
  syscall: 'listen',
  address: '127.0.0.1',
  port: 45886
}

Node.js v24.18.0
…(repeats until Ctrl+C)

Unit-level repro at the exact code path

File: 1558/repro/issue-1558-foreign-health.test.ts (copy to packages/bb-app/test/). waitForHealth is module-private, so the test needs this one-line change to src/launcher.ts (export-waitForHealth.diff):

diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts
index bfd962194..d4508fab3 100644
--- a/packages/bb-app/src/launcher.ts
+++ b/packages/bb-app/src/launcher.ts
@@ -2161,7 +2161,7 @@ export async function maybeAddAutoJoinEnv(
   };
 }
 
-async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
+export async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
   const timeoutMs = args.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
   const deadline = Date.now() + timeoutMs;
   while (Date.now() <= deadline) {

The test starts a plain node:http server that answers /health with 200 (instance one), spawns a child that exits with code 1 after 400 ms (instance two's server child dying on EADDRINUSE), and asks waitForHealth whether that child became healthy. On main it resolves ("healthy") on the first poll; the final assertion fails:

// Repro for get-bb/bb#1558: waitForHealth() accepts a 200 from an UNRELATED
// server on the configured port and reports the managed server child healthy,
// even though that child never bound the port (it exits shortly after with
// EADDRINUSE). On main this test FAILS at the final assertion:
// waitForHealth resolves on the very first poll because the foreign server
// answers before the child has even finished booting.
//
// Requires `waitForHealth` to be exported from src/launcher.ts (a one-line
// `export` was added for this repro; see the report).
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { describe, expect, it } from "vitest";
import { waitForHealth } from "../src/launcher.js";

async function listen(handler: Parameters<typeof createServer>[1]) {
  const server = createServer(handler);
  await new Promise<void>((resolvePromise, reject) => {
    server.once("error", reject);
    server.listen(0, "127.0.0.1", resolvePromise);
  });
  const address = server.address();
  if (address === null || typeof address === "string") {
    throw new Error("expected TCP address");
  }
  return {
    port: address.port,
    close: () =>
      new Promise<void>((resolvePromise, reject) => {
        server.close((error) => (error ? reject(error) : resolvePromise()));
      }),
  };
}

describe("issue #1558", () => {
  it("does not treat an unrelated server's /health as the managed child becoming healthy", async () => {
    // "Instance one": some other bb server already owning the port.
    let healthHits = 0;
    const foreign = await listen((request, response) => {
      if (request.url === "/health") healthHits += 1;
      response.writeHead(200, { "content-type": "application/json" });
      response.end(JSON.stringify({ ok: true }));
    });

    // "Instance two's server child": boots for a moment, then dies the way
    // apps/server does on EADDRINUSE (non-zero exit, never listens).
    const child = spawn(
      process.execPath,
      ["-e", "setTimeout(() => process.exit(1), 400)"],
      { stdio: "ignore" },
    );

    try {
      const outcome = await waitForHealth({
        childProcess: child,
        url: `http://127.0.0.1:${foreign.port}/health`,
        timeoutMs: 5_000,
      }).then(
        () => "healthy" as const,
        (error: unknown) =>
          error instanceof Error ? error.message : String(error),
      );

      // Wait for the child to actually exit so the assertion below is about
      // what waitForHealth decided, not about timing.
      await new Promise<void>((resolvePromise) => {
        if (child.exitCode !== null) return resolvePromise();
        child.once("exit", () => resolvePromise());
      });

      expect(child.exitCode).toBe(1);
      expect(healthHits).toBeGreaterThan(0);
      // BUG (main): outcome === "healthy" — the foreign 200 satisfied the check
      // before the doomed child even exited.
      expect(outcome).not.toBe("healthy");
    } finally {
      if (child.exitCode === null) child.kill("SIGKILL");
      await foreign.close();
    }
  });
});
$ cd packages/bb-app && pnpm exec vitest run test/issue-1558-foreign-health.test.ts

 RUN  v4.1.1 /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app

 ❯ test/issue-1558-foreign-health.test.ts (1 test | 1 failed) 430ms
     × does not treat an unrelated server's /health as the managed child becoming healthy 429ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  test/issue-1558-foreign-health.test.ts > issue #1558 > does not treat an unrelated server's /health as the managed child becoming healthy
AssertionError: expected 'healthy' not to be 'healthy' // Object.is equality
 ❯ test/issue-1558-foreign-health.test.ts:74:27
     72|       // BUG (main): outcome === "healthy" — the foreign 200 satisfied…
     73|       // before the doomed child even exited.
     74|       expect(outcome).not.toBe("healthy");
       |                           ^
     75|     } finally {
     76|       if (child.exitCode === null) child.kill("SIGKILL");

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed (1)
   Start at  07:43:08
   Duration  931ms (transform 244ms, setup 0ms, import 415ms, tests 430ms, environment 0ms)

(raw output.) The assertion that fails is expect(outcome).not.toBe("healthy"): waitForHealth returned success for a child that never listened and exited 1.

Root cause

1. The server readiness probe has no identity check. launcher.ts#L2164-L2186:

async function waitForHealth(args: WaitForHealthArgs): Promise<void> {
  const timeoutMs = args.timeoutMs ?? HEALTH_CHECK_TIMEOUT_MS;
  const deadline = Date.now() + timeoutMs;
  while (Date.now() <= deadline) {
    if (args.childProcess && (args.childProcess.exitCode !== null || args.childProcess.signalCode !== null)) {
      throw new Error("Process exited before becoming healthy");
    }
    try {
      const response = await fetch(args.url);
      if (response.ok) {
        return;                       // <-- any 200 from anyone on this port
      }
    } catch {}
    await new Promise<void>((resolvePromise) => { setTimeout(resolvePromise, HEALTH_CHECK_INTERVAL_MS); });
  }
  throw new Error(`Timed out waiting for health at ${args.url}`);
}

It is called from startFullStackServerProcess immediately after spawn (launcher.ts#L2898-L2927) with url = <serverUrl>/health, where /health is a constant { ok: true } (server.ts#L331). Because the child needs seconds to initDb, migrate, and load plugins before it reaches listen(), and the poll fires at t≈0 and every 100 ms, a foreign server on the port always wins. The child-exit guard only helps if the child dies before the first successful fetch, which it never does here. The issue's "there appears to be a race" is therefore too generous: it is the common case, not a window.

2. Success of that probe gates enrollment against the same URL. Right after ✓ Server listening, runBbApp calls maybeAddAutoJoinEnv (launcher.ts#L3349-L3354), which — when <dataDir>/auth.json is absent — POSTs /internal/hosts/enroll-key to context.serverUrl (launcher.ts#L2130-L2162, #L2105-L2128). That route is unauthenticated for loopback callers by design (internal/hosts.ts#L54-L80), so instance one happily mints a key and a fresh hostId in its database. The launcher passes them to the daemon as BB_HOST_ENROLL_KEY/BB_HOST_ID.

3. The daemon persists credentials before it binds anything and before the launcher's daemon identity check runs. start-host-daemon.ts#L159-L186: enrollDaemonHost(...) against serverUrl (instance one), then persistHostId + writeHostAuthState into its own dataDir (instance two). Only afterwards does it resolve the local API port (#L190-L198) and possibly fail with "port already in use". So even when the launcher ultimately exits 1 (both ports colliding), the poison auth.json is already on disk. The launcher's daemon check (waitForHostDaemonStatus, which verifies hostId/serverUrl) cannot catch this: in the variant it passes legitimately because the daemon really did connect, to the wrong server, with the host id the launcher was told to expect.

4. Why the later 401. Instance two's bb.db never saw the enrollment. On the next start its own server validates the daemon's bbdh_… key with the Better Auth apikey plugin, which logs INVALID_API_KEY and yields 401 unauthorized from /internal/hosts/session; the daemon treats 401 as non-retryable (server-connection.ts#L402-L432), logs "Server rejected host credentials — this host is not registered with the server", and shuts down with reason: startup-failed; the launcher prints ✗ Host daemon failed to start and exits 1. Every subsequent start does the same, because maybeAddAutoJoinEnv sees auth.json and skips re-enrollment.

Contributing/adjacent defects observed while reproducing. (a) apps/server logs Server listening before the socket is actually bound: startHttpListener returns synchronously from @hono/node-server's serve() and the log follows immediately (start-server.ts#L219-L232); the EADDRINUSE arrives later as an unhandled 'error' event and crashes the process with a raw Node stack, no bb-authored diagnostic (compare the daemon's "Host daemon local API port … already in use" message from #1155). In the logs above you can see INFO: [server] Server listening {… dataDir: /tmp/bb1558-two} for a server that never listened. (b) The launcher's supervision loop restarts the server unconditionally and re-runs the same identity-free probe, so a permanently un-bindable server child produces an infinite ✓ Server restarted loop instead of a fatal error. (c) The pre-flight warnings in runBbApp (warnExistingDaemonLock, warnExistingRuntimeRecord) are all keyed on the data dir, so a port collision across data dirs is invisible to them.

Proposed fix (first principles)

  1. Make the server probe prove identity, not liveness. The launcher owns the server child, so it can hand it a per-launch secret: generate BB_SERVER_LAUNCH_ID = randomUUID() in startFullStackServerProcess, pass it in the child env, and have the server expose it — either as a field on /health ({ ok: true, launchId }, only when the env var is set) or on a small loopback-only /internal/launch-identity. waitForHealth (server flavour) then accepts only a 200 whose launchId matches, exactly like waitForHostDaemonStatus already requires the expected hostId/serverUrl. A foreign server returns no/other id → the loop keeps polling → the child exits with EADDRINUSE → "Process exited before becoming healthy" → launcher prints ✗ Server failed to start and, crucially, never reaches maybeAddAutoJoinEnv. This is launcher↔server, not server↔daemon, so no HOST_DAEMON_PROTOCOL_VERSION bump is needed; the launcher and server always ship together in the same package. Risk: the desktop app or other tooling that reads /health must tolerate the extra field (additive, fine); if using a dedicated route, gate it with assertLoopbackRequest. Also fix the misleading server log by using serve(..., listeningListener) (or the 'listening' event) and add an 'error' handler that turns EADDRINUSE into a bb-authored message and exit code.
  2. Cheap belt-and-braces: before spawning the server, probe /health once; if it already answers, refuse to start with "port <n> is already served by another bb server (data dir? see its bb-app-runtime.json); use BB_SERVER_PORT/--port". This is not sufficient alone (a race with a concurrently booting instance one remains), which is why 1 is the real fix.
  3. Give the doomed restart loop an exit: in superviseFullStackProcesses, stop restarting the server after N consecutive restarts that ended in exit-before-listen (or once the identity probe never matched), and exit 1 with the log-dir hint. Independent of 1 this turns the infinite ✓ Server restarted loop into a visible failure.
  4. Recovery hint (optional): extend the daemon's fatal 401/403 message to say the persisted auth.json in <dataDir> is not known to <serverUrl> and can be removed to re-enroll. Do not auto-delete: a wrong BB_SERVER_URL pointing at a legitimate remote server would then silently re-enroll a duplicate host.

Test to add with fix 1: the vitest above (asserting rejection), plus a positive one where a fake server answers /health with the expected launchId. Test for 3: drive superviseFullStackProcesses with a startServer stub whose child exits immediately and assert it gives up.

PR review

No open PRs are linked to this issue.

Related issues

Appendix

Commands run

# worktree /home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21
pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build                                    # 14 tasks, 13 cached
git fetch origin main; git log 16ceb3a54..origin/main --oneline -- packages/bb-app apps/host-daemon/src/start-host-daemon.ts apps/server/src/start-server.ts   # empty
git show desktop-v0.35.1:packages/bb-app/src/launcher.ts | grep -n "async function waitForHealth" -A 22   # identical to base

bash 1558/repro/start-one.sh                                 # instance one, /tmp/bb1558-one, ports 45886/45887
bash 1558/repro/start-two.sh                                 # instance two, /tmp/bb1558-two, same ports
cat /tmp/bb1558-two/auth.json; cat /tmp/bb1558-two/host-id
sqlite3 /tmp/bb1558-one/bb.db "select id,name from hosts"; sqlite3 /tmp/bb1558-two/bb.db "select id,name from hosts"
kill <launcher one pid>; bash 1558/repro/restart-two.sh       # instance two alone -> 401 / INVALID_API_KEY
bash 1558/repro/start-one.sh; bash 1558/repro/start-two-daemon-port-differs.sh; bash 1558/repro/wait-restarts.sh <pid>   # variant: "bb is ready" + restart loop
kill <both launcher pids>; ss -ltn | grep -E "4588[6-9]"       # nothing left listening

# unit repro
sed -i 's/^async function waitForHealth(/export async function waitForHealth(/' packages/bb-app/src/launcher.ts
cp 1558/repro/issue-1558-foreign-health.test.ts packages/bb-app/test/
cd packages/bb-app && pnpm exec vitest run test/issue-1558-foreign-health.test.ts    # 1 failed (expected on main)

Repro scripts

# start-one.sh
#!/usr/bin/env bash
# Instance ONE: data dir /tmp/bb1558-one, ports 45886 (server) / 45887 (daemon)
set -u
BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js
rm -rf /tmp/bb1558-one; mkdir -p /tmp/bb1558-one
cd /tmp
BB_DATA_DIR=/tmp/bb1558-one BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \
  nohup node "$BBAPP" > /tmp/bb1558-one/launcher.log 2>&1 &
echo "one pid=$!"

# start-two.sh
#!/usr/bin/env bash
# Instance TWO: DIFFERENT data dir /tmp/bb1558-two, SAME ports 45886 / 45887
set -u
BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js
rm -rf /tmp/bb1558-two; mkdir -p /tmp/bb1558-two
cd /tmp
BB_DATA_DIR=/tmp/bb1558-two BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \
  nohup node "$BBAPP" > /tmp/bb1558-two/launcher.log 2>&1 &
echo "two pid=$!"

# restart-two.sh
#!/usr/bin/env bash
# Instance TWO again, keeping its data dir (and the mis-enrolled auth.json)
set -u
BBAPP=/home/sawyer/projects/bb/.claude/worktrees/wf_242c3e11-a10-21/packages/bb-app/dist/bb-app.js
cd /tmp
BB_DATA_DIR=/tmp/bb1558-two BB_SERVER_PORT=45886 BB_HOST_DAEMON_PORT=45887 \
  nohup node "$BBAPP" > /tmp/bb1558-two/launcher-restart.log 2>&1 &
echo "two pid=$!"

Instance one launcher log (for reference)

  bb

  ○  Starting server
  ✓  Server listening on http://127.0.0.1:45886
  ○  Starting host daemon
  ✓  Host daemon running

  ●  bb is ready

     app    http://127.0.0.1:45886
     daemon 45887
     data   /tmp/bb1558-one
     db     /tmp/bb1558-one/bb.db
     logs   /tmp/bb1558-one/logs/
     lock   /tmp/bb1558-one/daemon.lock

     Press Ctrl+C to stop

[07:39:29] INFO: [server] Server listening {"bindHost":"127.0.0.1","port":45886,"dataDir":"/tmp/bb1558-one"}
[07:39:31] INFO: [host-daemon] Host daemon connecting {"serverUrl":"http://127.0.0.1:45886","dataDir":"/tmp/bb1558-one"}
[07:39:31] INFO: [server] plugin automations@0.1.0 loaded
[07:39:32] INFO: [server] plugin connect@0.1.0 loaded
[07:39:32] INFO: [server] Session opened {"sessionId":"hses_72p5ybgsvf","hostId":"host_yej96dezaf","replacedSessionId":null}
[07:39:33] INFO: [server] plugin custom-instructions@0.1.0 loaded
[07:39:33] INFO: [server] Daemon WebSocket opened {"sessionId":"hses_72p5ybgsvf","hostId":"host_yej96dezaf"}
[07:39:33] INFO: [host-daemon] Connected to server {"serverUrl":"http://127.0.0.1:45886","sessionId":"hses_72p5ybgsvf"}
[07:39:33] INFO: [host-daemon] Host daemon started {"serverUrl":"http://127.0.0.1:45886","identity":{"hostId":"host_yej96dezaf","hostName":"bee","instanceId":"b2c0f361-dc4b-404b-a18d-a851826155f6"}}
[07:39:34] INFO: [server] plugin inline-vis@0.1.0 loaded
[07:39:35] INFO: [server] plugin keep-awake@0.1.0 loaded
[07:39:35] WARN: [server] marketplace bb-community entry "cascade" icon https://getbb.app/marketplace/v1/icons/cascade-7b651515.svg was rejected: 
[07:39:35] WARN: [server] marketplace bb-community entry "ntfy" icon https://getbb.app/marketplace/v1/icons/ntfy-2ef77317.svg was rejected: 
[07:39:35] WARN: [server] marketplace bb-community entry "slopcop" icon https://getbb.app/marketplace/v1/icons/slopcop-35963518.svg was rejected: 
[07:39:35] WARN: [server] marketplace bb-community entry "sticky-notes" icon https://getbb.app/marketplace/v1/icons/sticky-notes-5dc1948a.svg was rejected: 
[07:39:35] INFO: [server] Event loop stalled {"intervalMs":5000,"maxDelayMs":1461.7,"meanDelayMs":476.3,"p99DelayMs":1461.7,"resolutionMs":20,"thresholdMs":500,"currentWork":null,"lastWork":"GET /internal/ws","lastWorkMs":2.1,"slowestWork":"POST /internal/hosts/enroll-key","slowestWorkMs":32.5}
[07:39:35] INFO: [server] plugin provider-acp@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-claude-code@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-codex@0.1.0 loaded
[07:39:35] INFO: [server] plugin provider-pi@0.1.0 loaded
[07:39:35] INFO: [server] plugin secrets@0.1.0 loaded
[07:39:35] INFO: [host-daemon] Host plugin worker ready {"serverUrl":"http://127.0.0.1:45886","pluginId":"keep-awake","startupDurationMs":47}
[07:39:35] INFO: [server] plugin side-chat@0.1.0 loaded
[07:39:40] INFO: [server] Event loop stalled {"intervalMs":5000,"maxDelayMs":644.3,"meanDelayMs":23.1,"p99DelayMs":24.3,"resolutionMs":20,"thresholdMs":500,"currentWork":null,"lastWork":"sweep:database-maintenance","lastWorkMs":0.3,"slowestWork":"GET /internal/plugins/keep-awake/host/75222f025051f4b98f6adc862e86f65033d3564769383e59ff1e1b694d039b34","slowestWorkMs":4.1}