What
A follower tab's start(secret, accountId) resolves against a leader hosting a different account, and every later command then runs on that leader's engine.
Mechanism
EngineClient.start (packages/client/src/engineClient.ts) forwards to the live transport:
- Leader path —
LocalTransport.start posts accountId to the worker, and EngineHost.engineFor (packages/client/src/worker/engineHost.ts) refuses a second account: if (current.accountId !== id) throw refuse('alreadyStarted', 'another account holds this engine').
- Follower path —
BroadcastTransport.start() (packages/client/src/broadcastTransport.ts) takes no parameters at all. It returns leaderReady, which settles on the cb:leader beacon. Nothing compares the account the follower is starting for against the account the leader's engine actually opened.
So the guard that makes per-account durable-store namespacing meaningful (makeBrowserSeams: "two accounts signed in on one origin never share one") exists only on the leader path. Two tabs on one origin, signed into different accounts, put the follower's UI on the leader's vault — reads render the wrong account's tree, and an upload from that tab lands in the wrong account's vault.
Same origin is the stated trust boundary (packages/client/src/broadcast.ts header), so this is a cross-account correctness/containment gap rather than a cross-origin escape — but it is exactly the confusion per-account namespacing is meant to deny.
Shape of the fix
Carry the account through the follower-to-leader handshake and fail closed on a mismatch. The port adoption (cb:portHello / cb:portReady in packages/client/src/broadcast.ts) is the natural carrier: it is already private to one leadership and authenticated by the leadership token.
Two design questions have to be settled first, which is why this is not a review-fix:
- Liveness. A leader elected before anyone logged in has no account yet. A follower
start that waits for the leader's account would hang against such a leader. The handshake needs a defined answer for "leader not started" that neither hangs nor silently accepts.
- Ordering.
BroadcastTransport.start currently resolves without brokering a port. Making it account-checked means either brokering at start, or re-announcing the leader beacon once the leader's engine starts.
Acceptance
- A follower whose
accountId differs from the leader's live engine account has its start rejected, and issues no command against that leader.
- A follower whose
accountId matches starts exactly as today.
- A two-tab browser test in
packages/client/test/browser/ covers the mismatch, alongside the existing leadership suites.
Provenance
Raised as an outside-diff review finding on #1311 and verified against that branch; deferred there because the fix is a wire-protocol change with the two open design questions above, not a review edit.
What
A follower tab's
start(secret, accountId)resolves against a leader hosting a different account, and every later command then runs on that leader's engine.Mechanism
EngineClient.start(packages/client/src/engineClient.ts) forwards to the live transport:LocalTransport.startpostsaccountIdto the worker, andEngineHost.engineFor(packages/client/src/worker/engineHost.ts) refuses a second account:if (current.accountId !== id) throw refuse('alreadyStarted', 'another account holds this engine').BroadcastTransport.start()(packages/client/src/broadcastTransport.ts) takes no parameters at all. It returnsleaderReady, which settles on thecb:leaderbeacon. Nothing compares the account the follower is starting for against the account the leader's engine actually opened.So the guard that makes per-account durable-store namespacing meaningful (
makeBrowserSeams: "two accounts signed in on one origin never share one") exists only on the leader path. Two tabs on one origin, signed into different accounts, put the follower's UI on the leader's vault — reads render the wrong account's tree, and an upload from that tab lands in the wrong account's vault.Same origin is the stated trust boundary (
packages/client/src/broadcast.tsheader), so this is a cross-account correctness/containment gap rather than a cross-origin escape — but it is exactly the confusion per-account namespacing is meant to deny.Shape of the fix
Carry the account through the follower-to-leader handshake and fail closed on a mismatch. The port adoption (
cb:portHello/cb:portReadyinpackages/client/src/broadcast.ts) is the natural carrier: it is already private to one leadership and authenticated by the leadership token.Two design questions have to be settled first, which is why this is not a review-fix:
startthat waits for the leader's account would hang against such a leader. The handshake needs a defined answer for "leader not started" that neither hangs nor silently accepts.BroadcastTransport.startcurrently resolves without brokering a port. Making it account-checked means either brokering atstart, or re-announcing the leader beacon once the leader's engine starts.Acceptance
accountIddiffers from the leader's live engine account has itsstartrejected, and issues no command against that leader.accountIdmatches starts exactly as today.packages/client/test/browser/covers the mismatch, alongside the existing leadership suites.Provenance
Raised as an outside-diff review finding on #1311 and verified against that branch; deferred there because the fix is a wire-protocol change with the two open design questions above, not a review edit.