#3969 · Browser session CLI forwards machine names as host IDs

BugMedium priorityLow effortplugins, cliGitHub issue · 2026-09-21 · base b8866c6e1dbb1e9b043df3d4474178459a5b4f9a

Verdict: REPRODUCED · Root-cause confidence: high · Reproduction scope: real plugin CLI/session code in the existing in-process SDK harness.

1. TL;DR

The browser automation CLI accepts a machine selector but passes its text directly into a host-ID field. The server plugin does not translate that name before creating the session and preparing the host worker. A focused regression test confirms that a friendly name remains the session host ID, while the exact-ID control works. Core CLI commands already resolve exact IDs or unambiguous names. The same test failed again in a second clean checkout; no user's live runtime or browser was accessed.

2. Claims vs findings

ClaimFindingEvidence
Names fail while IDs workVerified at the plugin boundaryBoth runs: local name remains hostId; desktop name fails the connection-host check; exact-ID control passes.
The CLI resolves names before host dispatchRefutedNo resolution occurs in CLI parsing or executeCli.
The connection error means the daemon is offlineNot necessarilyThe hub looks up a session by the supplied host ID; a name is a different map key.
The plugin help advertises namesRefuted for this trusted baseIts own option shows host-id and “Host that runs the browser”; the core machine CLI supports names.
All reported machines heartbeat normally; warnings recurUnverifiedNo access to reporter runtime. Source shows session saving precedes worker preparation, so failed routing can also affect cleanup; no live cleanup-warning reproduction was attempted.

3. Environment

Trusted origin/main at b8866c6e1dbb1e9b043df3d4474178459a5b4f9a; macOS / Darwin arm64, Node v22.22.3, pinned pnpm 9.15.0 via Corepack, Vitest 4.1.1. Separate temporary base and verification worktrees at the same commit, each installed using the frozen lockfile. The normal Turbo build was started. The source-level reproduction bypassed the still-running SDK declaration build deliberately; final fix validation uses Turbo.

Real plugin factory, CLI parser, contracts, session code and SQLite-backed SDK test harness. Host-list responses and worker RPC are test fixtures. No browser, provider, network daemon, ports, imported store or live BB data directory was used. This reproduces wrong routing, not the reporter's daemon connection state or exact end-to-end error message.

4. Minimal reproduction

Save the complete inline regression patch below as a local file and apply it to the pinned commit and run:

git clone https://github.com/get-bb/bb.git issue-3969-check
cd issue-3969-check
git checkout b8866c6e1dbb1e9b043df3d4474178459a5b4f9a
corepack pnpm install --frozen-lockfile --prefer-offline
# Save the inline regression patch below to a local file first.
git apply /path/to/regression.patch
cd plugins/browser-automation
corepack pnpm exec vitest run --config vitest.config.ts server.test.ts --testNamePattern='machine (selector|ID)' 

Expected: all six selector cases pass. Actual in both checkouts:

Tests  5 failed | 1 passed | 12 skipped (18)
Local name case:
- "hostId": "local-host"
+ "hostId": "Lab workstation"
Desktop name case:
Desktop connection returned a different execution host
Unknown / ambiguous name cases:
expected +0 to be 1

The worker fixture accepts the local route, allowing the test to observe the incorrect hostId directly; it does not fabricate a daemon connectivity error. The desktop fixture returns the canonical host, exposing the mismatch. The rejection cases verify that invalid selectors must stop before worker calls or session creation.

Complete regression patch
diff --git a/plugins/browser-automation/server.test.ts b/plugins/browser-automation/server.test.ts
index de75322d6..9130e7b3a 100644
--- a/plugins/browser-automation/server.test.ts
+++ b/plugins/browser-automation/server.test.ts
@@ -1,6 +1,7 @@
 import {
   createFakePluginHost,
   makePluginAgentConfigurationContext,
+  makeHostResponse,
   makeThreadResponse,
 } from "@get-bb/plugin-sdk/testing";
 import { describe, expect, it, vi } from "vitest";
@@ -94,6 +95,10 @@ async function setup() {
       { tabId: "created", profile: { kind: "automation", id: "profile" } },
     ],
   }));
+  host.harness.sdk.stub("hosts.list", async () => [
+    makeHostResponse({ id: "local-host", name: "Lab workstation" }),
+    makeHostResponse({ id: "desktop-host", name: "Lab desktop" }),
+  ]);
   await plugin(host.bb);
   async function open(tabId?: string) {
     const result = await host.harness.behavior.callRpc("open", {
@@ -111,6 +116,102 @@ async function setup() {
 }
 
 describe("server session ownership", () => {
+  it.each([
+    ["local", "Lab workstation", "local-host"],
+    ["desktop", "Lab desktop", "desktop-host"],
+    ["local", "local-host", "local-host"],
+  ])(
+    "resolves %s machine selector %s before opening",
+    async (backend, target, hostId) => {
+      const h = await setup();
+      try {
+        const result = await h.harness.behavior.runCli(
+          [
+            "open",
+            "--backend",
+            backend,
+            "--machine",
+            target,
+            ...(backend === "local"
+              ? ["--headless"]
+              : ["--desktop", "desktop"]),
+            "--json",
+          ],
+          { threadId: "thread-test" },
+        );
+        expect(result.exitCode, result.stderr).toBe(0);
+        expect(JSON.parse(result.stdout)).toMatchObject({ hostId });
+        expect(h.worker).toHaveBeenCalledWith(
+          expect.objectContaining({ method: "prepare", hostId }),
+        );
+      } finally {
+        await h.harness.lifecycle.dispose();
+      }
+    },
+  );
+
+  it("prefers an exact machine ID over a matching name", async () => {
+    const h = await setup();
+    h.harness.sdk.stub("hosts.list", async () => [
+      makeHostResponse({ id: "other-host", name: "local-host" }),
+      makeHostResponse({ id: "local-host", name: "Lab workstation" }),
+    ]);
+    try {
+      const result = await h.harness.behavior.runCli(
+        [
+          "open",
+          "--backend",
+          "local",
+          "--machine",
+          " local-host ",
+          "--headless",
+          "--json",
+        ],
+        { threadId: "thread-test" },
+      );
+      expect(result.exitCode, result.stderr).toBe(0);
+      expect(JSON.parse(result.stdout)).toMatchObject({ hostId: "local-host" });
+    } finally {
+      await h.harness.lifecycle.dispose();
+    }
+  });
+
+  it.each([
+    ["Shared lab", "ambiguous"],
+    ["Missing lab", "not found"],
+  ])(
+    "rejects machine selector %s before creating a session",
+    async (target, message) => {
+      const h = await setup();
+      h.harness.sdk.stub("hosts.list", async () => [
+        makeHostResponse({ id: "host-a", name: "Shared lab" }),
+        makeHostResponse({ id: "host-b", name: "Shared lab" }),
+      ]);
+      try {
+        const result = await h.harness.behavior.runCli(
+          [
+            "open",
+            "--backend",
+            "local",
+            "--machine",
+            target,
+            "--headless",
+            "--json",
+          ],
+          { threadId: "thread-test" },
+        );
+        expect(result.exitCode).toBe(1);
+        expect(result.stderr).toContain(message);
+        expect(h.worker).not.toHaveBeenCalled();
+        expect(
+          await h.harness.behavior.callRpc("list", { threadId: "thread-test" }),
+        ).toEqual([]);
+      } finally {
+        await h.harness.lifecycle.dispose();
+      }
+    },
+  );
+
   it("returns browser-host image paths through the CLI without registering tools", async () => {
     const h = await setup();
     try {

5. Root cause

  1. CLI parsing assigns input.options.machine to hostId without lookup.
  2. Server CLI execution dispatches that selection unchanged.
  3. Session creation stores it as the session host. Worker preparation calls the host client using that value.
  4. Plugin host RPC forwards the host ID. Online session lookup indexes daemon sessions by host ID, and Error translation maps unavailable routing to the connection error.

Unlike this plugin, the core CLI resolver trims the selector, prioritizes exact ID matches, and rejects unknown or non-unique names. This is a missing CLI normalization step, not evidence of a broken daemon heartbeat.

6. Proposed fix

Resolve the CLI open selector using the existing SDK hosts list before dispatch. Preserve ID precedence, reject ambiguous and missing names, and carry the canonical ID into the unchanged session and RPC contracts. Update the option help, plugin skill and browser guide. Keep resolution at the CLI boundary: typed RPC consumers continue supplying host IDs.

7. Verification

The same agent repeated the test in a second newly created worktree, <temporary-checkout-verify>, at the exact trusted base commit with a separate frozen install. Only the authored regression patch was applied. The command and assertions were identical. Both runs failed five selector cases and passed the raw-ID control. This second run supports the report; no correction to the routing finding was needed. It is not an independent-agent review.

8. Related issues and PRs

No linked open PR was present in the issue timeline or issue-number PR search when checked. A small repository search found other browser-control reports but no verified duplicate of this selector defect. No linked PR code was checked out or executed.

9. Appendix

First-run test output
 RUN  v4.1.1 <temporary-checkout-base>/plugins/browser-automation

 ❯ |bb-plugin-browser-automation| server.test.ts (18 tests | 5 failed | 12 skipped) 700ms
     × resolves local machine selector Lab workstation before opening 249ms
     × resolves desktop machine selector Lab desktop before opening 197ms
     × prefers an exact machine ID over a matching name 69ms
     × rejects machine selector Shared lab before creating a session 5ms
     × rejects machine selector Missing lab before creating a session 4ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > resolves local machine selector Lab workstation before opening
AssertionError: expected { …(8) } to match object { hostId: 'local-host' }
(7 matching properties omitted from actual)

- Expected
+ Received

  {
-   "hostId": "local-host",
+   "hostId": "Lab workstation",
  }

 ❯ server.test.ts:143:43
    141|         );
    142|         expect(result.exitCode, result.stderr).toBe(0);
    143|         expect(JSON.parse(result.stdout)).toMatchObject({ hostId });
       |                                           ^
    144|         expect(h.worker).toHaveBeenCalledWith(
    145|           expect.objectContaining({ method: "prepare", hostId }),

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

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > resolves desktop machine selector Lab desktop before opening
AssertionError: Desktop connection returned a different execution host
: expected 1 to be +0 // Object.is equality

- Expected
+ Received

- 0
+ 1

 ❯ server.test.ts:142:48
    140|           { threadId: "thread-test" },
    141|         );
    142|         expect(result.exitCode, result.stderr).toBe(0);
       |                                                ^
    143|         expect(JSON.parse(result.stdout)).toMatchObject({ hostId });
    144|         expect(h.worker).toHaveBeenCalledWith(

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > prefers an exact machine ID over a matching name
AssertionError: expected { …(8) } to match object { hostId: 'local-host' }
(7 matching properties omitted from actual)

- Expected
+ Received

  {
-   "hostId": "local-host",
+   "hostId": " local-host ",
  }

 ❯ server.test.ts:173:41
    171|       );
    172|       expect(result.exitCode, result.stderr).toBe(0);
    173|       expect(JSON.parse(result.stdout)).toMatchObject({ hostId: "local…
       |                                         ^
    174|     } finally {
    175|       await h.harness.lifecycle.dispose();

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > rejects machine selector Shared lab before creating a session
 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > rejects machine selector Missing lab before creating a session
AssertionError: expected +0 to be 1 // Object.is equality

- Expected
+ Received

- 1
+ 0

 ❯ server.test.ts:203:33
    201|           { threadId: "thread-test" },
    202|         );
    203|         expect(result.exitCode).toBe(1);
       |                                 ^
    204|         expect(result.stderr).toContain(message);
    205|         expect(h.worker).not.toHaveBeenCalled();

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯


 Test Files  1 failed (1)
      Tests  5 failed | 1 passed | 12 skipped (18)
   Start at  17:08:43
   Duration  10.34s (transform 5.80s, setup 0ms, import 7.47s, tests 700ms, environment 0ms)

Second clean checkout test output
 RUN  v4.1.1 <temporary-checkout-verify>/plugins/browser-automation

 ❯ |bb-plugin-browser-automation| server.test.ts (18 tests | 5 failed | 12 skipped) 468ms
     × resolves local machine selector Lab workstation before opening 21ms
     × resolves desktop machine selector Lab desktop before opening 3ms
     × prefers an exact machine ID over a matching name 134ms
     × rejects machine selector Shared lab before creating a session 10ms
     × rejects machine selector Missing lab before creating a session 7ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 5 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > resolves local machine selector Lab workstation before opening
AssertionError: expected { …(8) } to match object { hostId: 'local-host' }
(7 matching properties omitted from actual)

- Expected
+ Received

  {
-   "hostId": "local-host",
+   "hostId": "Lab workstation",
  }

 ❯ server.test.ts:143:43
    141|         );
    142|         expect(result.exitCode, result.stderr).toBe(0);
    143|         expect(JSON.parse(result.stdout)).toMatchObject({ hostId });
       |                                           ^
    144|         expect(h.worker).toHaveBeenCalledWith(
    145|           expect.objectContaining({ method: "prepare", hostId }),

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

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > resolves desktop machine selector Lab desktop before opening
AssertionError: Desktop connection returned a different execution host
: expected 1 to be +0 // Object.is equality

- Expected
+ Received

- 0
+ 1

 ❯ server.test.ts:142:48
    140|           { threadId: "thread-test" },
    141|         );
    142|         expect(result.exitCode, result.stderr).toBe(0);
       |                                                ^
    143|         expect(JSON.parse(result.stdout)).toMatchObject({ hostId });
    144|         expect(h.worker).toHaveBeenCalledWith(

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[2/5]⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > prefers an exact machine ID over a matching name
AssertionError: expected { …(8) } to match object { hostId: 'local-host' }
(7 matching properties omitted from actual)

- Expected
+ Received

  {
-   "hostId": "local-host",
+   "hostId": " local-host ",
  }

 ❯ server.test.ts:173:41
    171|       );
    172|       expect(result.exitCode, result.stderr).toBe(0);
    173|       expect(JSON.parse(result.stdout)).toMatchObject({ hostId: "local…
       |                                         ^
    174|     } finally {
    175|       await h.harness.lifecycle.dispose();

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[3/5]⎯

 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > rejects machine selector Shared lab before creating a session
 FAIL  |bb-plugin-browser-automation| server.test.ts > server session ownership > rejects machine selector Missing lab before creating a session
AssertionError: expected +0 to be 1 // Object.is equality

- Expected
+ Received

- 1
+ 0

 ❯ server.test.ts:203:33
    201|           { threadId: "thread-test" },
    202|         );
    203|         expect(result.exitCode).toBe(1);
       |                                 ^
    204|         expect(result.stderr).toContain(message);
    205|         expect(h.worker).not.toHaveBeenCalled();

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[4/5]⎯


 Test Files  1 failed (1)
      Tests  5 failed | 1 passed | 12 skipped (18)
   Start at  17:09:36
   Duration  13.10s (transform 6.61s, setup 0ms, import 9.25s, tests 468ms, environment 0ms)

Issue text was treated as untrusted claims. Commands and test code were derived from trusted repository source; issue-provided scripts, external URLs and patches were not executed.