#4837 · Source grant acquisition runs behind its own write freeze
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 claim | Finding | Evidence / limit |
|---|---|---|
| Source grant lookup runs after the write freeze. | Verified | Trusted coordinator sequencing and both failing runs. |
| The account RPC receives HTTP 503 while the move resolves its grant. | Verified at the transport boundary | The 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 source | Account-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. | Unverified | No 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-reproduced | The server access resolver persists acquisition errors in the host status message. |
3. Environment
- Public repository
get-bb/bb; trusted main commit4d15c1da0a848fa4834c1e5d0480a0891683bbe9; package version 0.45.0. - Linux x86_64; Node v22.19.0; pnpm 9.15.0; Vitest 4.1.1.
- Frozen dependency install and normal Turbo build succeeded in both checkouts.
- Real migrated SQLite test harness; fresh temporary data directories generated per test and cleaned by the harness. HTTP requests use Hono's in-process request API; no listening server, production instance, cloud credentials, or agent provider is needed.
- The source grant callback uses the production freeze middleware with the same database freeze state as the coordinator. The successful account handler and daemon replies are fixtures: this is a lifecycle/HTTP-boundary reproduction, not a live account or desktop end-to-end test.
4. Minimal reproduction
- 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
- Save the complete agent-authored regression change shown below as
regression.patchin 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'
- 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
apps/server/src/services/server-move/coordinator.ts:544sets the write freeze at line 546, stops running work, and only then resolves the source host grant at lines 557–566.apps/server/src/services/server-move/environment.ts:144delegates grant resolution to the server access subsystem.plugins/connect/src/server-access.ts:129can reuse a stored result, but otherwise mints a machine code and redeems it to obtain a grant.plugins/connect/src/account-client.ts:133usessdk.plugins.callRpcforbb-account.v1.fetch.packages/sdk/src/areas/plugins.ts:418sends that operation over HTTP POST.apps/server/src/server.ts:828applies the global freeze middleware to/api/v1/*.apps/server/src/services/server-move/freeze.ts:4allows read methods and server-move routes, not account RPC POSTs. The rejection is503 server_moving.plugins/connect/src/account-client.ts:147turns HTTP 503 intoAccountUnavailableError;plugins/connect/src/hosted.ts:150maps that toMachineCodeError("not_paired").apps/server/src/services/plugins/plugin-service.ts:1403converts a failed isolated provider call to the generic server-access error, obscuring the freeze rejection.apps/server/src/services/machines/server-access.ts:178persists 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.