← reports

#4837 · Source grant acquisition runs behind its own write freeze

BugHighEffort: Lowremote · connectGitHub issue2026-10-04 · base 4d15c1da0a848fa4834c1e5d0480a0891683bbe9

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

1. TL;DR

A Connect server move can block the account RPC needed to obtain its source machine's access grant. The coordinator freezes public writes before it asks for that grant. The account RPC is a POST, so the actual write-freeze middleware rejects it with HTTP 503. An isolated regression exercising the real coordinator, a migrated SQLite database, and the real HTTP middleware reproduced this failure twice on trusted main. Resolving the grant before freezing writes lets the same regression finish the move without exempting any RPC from the freeze.

2. Claims vs findings

Reported claimFindingEvidence / limit
Source grant lookup runs after the write freeze.VerifiedTrusted coordinator sequencing and both failing runs.
The account RPC receives HTTP 503 while the move resolves its grant.Verified at the transport boundaryThe real middleware returned 503 for the account fetch RPC POST during source grant resolution. The unfrozen endpoint returns 200 after the sequencing fix.
Connect uses the account plugin and reduces unavailability to a pairing error.Verified in sourceAccount-client RPC transport and hosted machine-code error mapping. No authenticated cloud request was made.
All moves on the reported macOS installation fail, including five retries and target re-enrollment.UnverifiedNo reporter desktop, credentials, or VM was used. Existing saved grants can avoid the account-fetch acquisition path.
The error also appears in machine settings.Source-supported, not UI-reproducedThe server access resolver persists acquisition errors in the host status message.

3. Environment

4. Minimal reproduction

  1. Create a fresh checkout of the public main commit and install its locked dependencies.
    git clone https://github.com/get-bb/bb.git bb-4837
    cd bb-4837
    git checkout --detach 4d15c1da0a848fa4834c1e5d0480a0891683bbe9
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Save the complete agent-authored regression change shown below as regression.patch in this checkout and apply it. It extends the existing Connect move test to send an HTTP account fetch RPC through the real freeze middleware during grant acquisition. Production code remains unchanged.
    git apply regression.patch
    pnpm exec turbo run test --filter=@bb/server -- test/server-move/coordinator.test.ts -t 'source grant requires'
  3. Expected: account RPC returns 200, then the source grant is handed to the target and the move retires the source. Actual on main: account RPC returns 503 and the move does not complete.
    AssertionError: expected [ 503 ] to deeply equal [ 200 ]
    
    - Expected
    + Received
    
      [
    -   200,
    +   503,
      ]
    
    Test Files  1 failed (1)
    Tests  1 failed | 23 skipped (24)
Complete regression test change
diff --git a/apps/server/test/server-move/coordinator.test.ts b/apps/server/test/server-move/coordinator.test.ts
index 941e81ba0..fb1c4945e 100644
--- a/apps/server/test/server-move/coordinator.test.ts
+++ b/apps/server/test/server-move/coordinator.test.ts
@@ -13,8 +13,11 @@ import {
 } from "@bb/server-archive";
 import type { ServerMoveStatus } from "@bb/server-contract";
 import { createDeferredPromise } from "@bb/test-helpers";
+import { Hono } from "hono";
 import { describe, expect, it, vi } from "vitest";
+import { createServerErrorHandler } from "../../src/errors.js";
 import { createServerMoveCoordinator } from "../../src/services/server-move/coordinator.js";
+import { serverMoveFreezeMiddleware } from "../../src/services/server-move/freeze.js";
 import {
   isServerMoveFrozen,
   isServerMoveSnapshotFenced,
@@ -32,7 +35,11 @@ import {
   type FakeDaemonReply,
 } from "../helpers/server-move.js";
 import { seedHost, seedPrimaryHost } from "../helpers/seed.js";
-import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js";
+import {
+  testLogger,
+  withTestHarness,
+  type TestAppHarness,
+} from "../helpers/test-app.js";
 
 const OLD = "host-old";
 const NEW = "host-new";
@@ -550,12 +557,24 @@ describe("server move coordinator", () => {
       await expect.poll(() => events.includes("retire")).toBe(true);
     }));
 
-  it("moves a bb connect server with the old server's own grant and no address probe", () =>
+  it("moves a bb connect server whose source grant requires a writable account RPC", () =>
     withTestHarness(async (harness) => {
       seedTopology(harness);
       const base = createTestServerMoveEnvironment(harness);
       const { events } = base;
       const grantHeaders = { "x-bb-connect-machine": "bbcm_laptop" };
+      const accountRpcStatuses: number[] = [];
+      const accountRpcPath =
+        "/api/v1/plugins/bb-account/rpc/bb-account.v1.fetch";
+      const accountRpc = new Hono();
+      accountRpc.onError(createServerErrorHandler(testLogger));
+      accountRpc.use(
+        "/api/v1/*",
+        serverMoveFreezeMiddleware({
+          isFrozen: () => isServerMoveFrozen(harness.db),
+        }),
+      );
+      accountRpc.post(accountRpcPath, (context) => context.json({ ok: true }));
       const coordinator = createServerMoveCoordinator({
         ...base.environment,
         resolveMode: async () => ({
@@ -565,6 +584,13 @@ describe("server move coordinator", () => {
         }),
         resolveServerHostGrant: async (hostId) => {
           events.push(`grant:${hostId}`);
+          const response = await accountRpc.request(accountRpcPath, {
+            method: "POST",
+          });
+          accountRpcStatuses.push(response.status);
+          if (!response.ok) {
+            throw new Error(await response.text());
+          }
           return { serverUrl: CONNECT_URL, headers: grantHeaders };
         },
       });
@@ -593,6 +619,7 @@ describe("server move coordinator", () => {
         serverUrl: CONNECT_URL,
         destinationStatusUrl: null,
       });
+      await expect.poll(() => accountRpcStatuses).toEqual([200]);
       await expect.poll(() => events.includes("retire")).toBe(true);
 
       expect(events).toEqual([

The inline patch contains the complete test change; all unchanged imports and helper setup come from the trusted repository test file. Run it in the existing repository test harness, not as a standalone script. Raw logs and the full extended test file are retained locally, outside the public reports repository.

5. Root cause

  1. apps/server/src/services/server-move/coordinator.ts:544 sets the write freeze at line 546, stops running work, and only then resolves the source host grant at lines 557–566.
  2. apps/server/src/services/server-move/environment.ts:144 delegates grant resolution to the server access subsystem. plugins/connect/src/server-access.ts:129 can reuse a stored result, but otherwise mints a machine code and redeems it to obtain a grant.
  3. plugins/connect/src/account-client.ts:133 uses sdk.plugins.callRpc for bb-account.v1.fetch. packages/sdk/src/areas/plugins.ts:418 sends that operation over HTTP POST.
  4. apps/server/src/server.ts:828 applies the global freeze middleware to /api/v1/*. apps/server/src/services/server-move/freeze.ts:4 allows read methods and server-move routes, not account RPC POSTs. The rejection is 503 server_moving.
  5. plugins/connect/src/account-client.ts:147 turns HTTP 503 into AccountUnavailableError; plugins/connect/src/hosted.ts:150 maps that to MachineCodeError("not_paired"). apps/server/src/services/plugins/plugin-service.ts:1403 converts a failed isolated provider call to the generic server-access error, obscuring the freeze rejection.
  6. apps/server/src/services/machines/server-access.ts:178 persists an acquisition error to the machine status message. This supports the reported side effect without claiming a UI reproduction.

Relevant trusted history: commit f9e0b51a6 introduced the account-plugin transport. No newer trusted main change to the investigated paths was present when checked.

6. Proposed fix

Resolve the source grant while the server and account plugin can still accept writes. Respect cancellation, then freeze writes, pause schedules, stop running work, and suspend plugins as before. This preserves all existing freeze exemptions and authentication/authorization behavior. Do not broadly exempt account or Connect RPC writes: the coordinator only needs this grant before entering its frozen stage.

The local fix changes two files in the server-move subsystem, with 55 added-plus-deleted text lines. It changes no dependency, stored-data format, public protocol, or security boundary. The focused regression passes after the fix; all 97 existing and updated server-move tests in 16 files pass, including freeze fences, cancellation, recovery, and handoff. Server typecheck, formatting checks, and git diff --check also pass.

7. Verification

The same agent repeated the reproduction in a second fresh temporary detached checkout at 4d15c1da0a848fa4834c1e5d0480a0891683bbe9 (temporary directory identifier redacted). Only the same regression-test patch was applied; production files remained identical to trusted main. This checkout performed its own frozen install and normal Turbo build and ran the command from section 4. The test again failed with actual [503] versus expected [200], proving the original finding was not a dirty-tree artifact. Turbo showed a cache miss for the server test in both failing runs. No substantive report correction was required. This is a second clean verification, not independent-agent verification.

AssertionError: expected [ 503 ] to deeply equal [ 200 ]

- Expected
+ Received

  [
-   200,
+   503,
  ]

Test Files  1 failed (1)
Tests  1 failed | 23 skipped (24)

8. Related issues

Nearby server-move reports cover interrupted target transfer and external-path plugin settings. Those are separate failure modes; this reproduction stops earlier at grant acquisition. GitHub cross-reference metadata and an open-PR search showed no existing open pull request linked to #4837 during investigation.

9. Appendix

Verbatim result summaries after the fix (full raw logs retained locally):

Focused regression:
Test Files  1 passed (1)
Tests  1 passed | 23 skipped (24)

Server-move suites:
Test Files  16 passed (16)
Tests  97 passed (97)

Server typecheck:
Tasks:    5 successful, 5 total

Formatting:
All matched files use the correct format.
pnpm exec turbo run test --filter=@bb/server -- test/server-move
pnpm exec turbo run typecheck --filter=@bb/server
pnpm exec oxfmt --check apps/server/src/services/server-move/coordinator.ts apps/server/test/server-move/coordinator.test.ts
git diff --check
git diff --numstat origin/main

Trust note: issue prose and proposed remedies were treated as untrusted hypotheses. No command, patch, linked branch, or external resource supplied by the issue was executed or fetched. The reproduction and fix were authored from trusted repository evidence. No real account information, credentials, production data, or home paths are published.