Skip to content

feat(core): coordinator holds the hub connection (gateway role) - #169

Merged
Mearman merged 8 commits into
mainfrom
feat/coordinator-gateway
Sep 17, 2026
Merged

Mearman merged 8 commits into
mainfrom
feat/coordinator-gateway

Conversation

@Mearman

@Mearman Mearman commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

First leg of the cross-machine mesh epic (#153): attaches the gateway role to the existing local coordinator-election machinery. A bridge that becomes this machine's mesh coordinator, whether by a fresh bind (MeshStore.init()) or a takeover (PeerLifecycle.handleBecomeCoordinator), now also dials the relay hub and holds the connection for as long as it holds the coordinator role. Losing the role drops the connection.

  • New CoordinatorGateway collaborator owns the connect/disconnect lifecycle, idempotently, and never lets a hub-dial failure propagate into local coordinator election. The local mesh must keep working with no internet or hub access, so a dial failure is reported via onError and swallowed rather than thrown.
  • MeshTransport gains optional connectHub/disconnectHub (same optionality precedent as listKnownDevices), implemented by WireMeshTransport on top of its existing HubSession, which now also exposes isConnected/disconnect().
  • The hub URL is configuration: MeshStore's constructor takes an optional hubUrl, threaded through createBridgeMesh/createBridgeMeshSync alongside the existing coordinatorPort, defaulting to wss://mesh.exadev.io/ (DEFAULT_HUB_URL in mesh-store-shared.ts).
  • MeshStore.shutdown() unconditionally calls onLostCoordinator(); the gateway's own connected guard makes that a no-op unless this instance actually held the role. A full transport shutdown also still closes the hub session as part of its existing session cleanup, so the connection drops on both graceful shutdown and crash.
  • The multi-process smoke test now points at a guaranteed-unreachable local address instead of the real default hub, so it stays offline/deterministic and exercises the dial-failure isolation for real.

Not in scope (tracked separately): forwarding local agents onto the hub / merging its directory back (#155), the gateway trust boundary / allowlist (#156).

Deliberately left open

PeerLifecycle.handleBecomeCoordinator's takeover path (a peer receiving a become_coordinator wire message) is wired into the gateway the same as the fresh-bind path and covered by unit tests, but nothing in this codebase currently sends become_coordinator. I found no producer anywhere in src/, despite the README describing "graceful handover to the longest-running peer" as live. That gap predates this issue and is outside #154's scope, since it's about the local coordinator-election protocol itself, not the gateway role riding on top of it, so I left it as-is rather than building a sender. Flagging it here since the epic's own text states the handover already exists.

Test plan

  • pnpm lint
  • pnpm typecheck
  • pnpm test (815 tests)
  • pnpm test:smoke (real multi-process, built-artifact smoke test, now hub-isolated)
  • New real-hub-over-ws integration coverage: WireMeshTransport.connectHub/disconnectHub, and a full createBridgeMesh becomes coordinator, real local hub sees the connection, shutdown drops it test

Closes #154

Dials the hub when a bridge becomes the local mesh coordinator and
drops the connection when it loses that role, idempotently. Not yet
wired into MeshStore/PeerLifecycle or the transport's own hub session.
realHubOverWs and waitForCondition were private to the one integration
test that needed them; a second hub integration test for the
coordinator gateway needs the identical harness plus a connection
count observable on the hub side, so it now lives in the shared
helpers module both tests import.
WireMeshTransport.connectHub/disconnectHub delegate to the transport's
own HubSession, which now exposes isConnected and disconnect() so a
caller can observe and end the held hub session without reaching into
private state. Both methods are optional on MeshTransport, matching
listKnownDevices' existing precedent for a WireMeshTransport-only
capability.

Extracts mergeKnownDevices out of WireMeshTransport into its own
gossip-directory module to keep the file under the repo's max-lines
cap after this addition, mirroring the existing peer-lifecycle.ts /
connection-approval.ts / room-router.ts / hub-session.ts splits.
MeshStore now constructs a CoordinatorGateway and calls
onBecameCoordinator() from both places this store takes over as
coordinator: init()'s own fresh-bind fallback and PeerLifecycle's
handleBecomeCoordinator takeover. shutdown() calls
onLostCoordinator() unconditionally; the gateway's own connected
guard makes that a no-op unless this instance actually held the
role. The hub URL defaults to DEFAULT_HUB_URL (mesh-store-shared.ts)
and is overridable via a new MeshStore constructor parameter.
Every bridge builds its MeshStore through this factory; hubUrl now
passes through to MeshStore's own constructor alongside the existing
coordinatorPort parameter, so a test (or a future non-default
deployment) can override which hub a bridge's coordinator gateway
dials without touching MeshStore directly.
CoordinatorGateway.onBecameCoordinator previously let a connectHub
rejection propagate all the way through MeshStore.init(), so a bridge
whose becomeCoordinator() succeeded locally would still fail to start
if the gateway's hub happened to be unreachable. The local mesh's own
coordinator election has no dependency on internet or hub
availability, so this coupled two things that were never meant to be
coupled. A dial failure is now reported via an injected onError
callback (wired through to MeshStore's own onError) and swallowed;
isConnected stays false so the next onBecameCoordinator call retries.
MeshStore's coordinator now dials a hub on becoming coordinator
(agent-comms#154); without an explicit override this smoke test would
have started making a real outbound connection to the production hub
on every CI run. Pointing it at a guaranteed-unreachable local address
keeps the test offline and deterministic, and exercises
CoordinatorGateway's own dial-failure isolation along the way.
@Mearman
Mearman force-pushed the feat/coordinator-gateway branch from ebae2a7 to 35ef7fe Compare September 17, 2026 14:43
@Mearman
Mearman marked this pull request as ready for review September 17, 2026 14:46
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 17, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
🔒 Security Review Completed 2026-09-17T14:56:06.705065Z 35ef7fe Draft marked ready

Security findings

Advisory findings (1)

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ Codex Security Review · Automatically triggered

Here are some automated security review suggestions for this pull request.

Reviewed commit: 35ef7fe596

ℹ️ About Codex security reviews in GitHub

This is an experimental Codex feature. Security reviews are triggered when:

  • You comment "@codex security review"
  • A regular code review gets triggered (for example, "@codex review" or when a PR is opened), and you’re opted in so security review runs alongside code review

Once complete, Codex will leave suggestions, or a comment if no findings are found.

Comment thread src/core/mesh-store.ts
A Codex security review on PR #169 found that HubSession.consume()
forwarded any hub-relayed state_sync/state_update straight into
applyPatch/applyStateSync with no per-peer admission control. That
path existed since #151, but #154's own change is what first made a
coordinator dial the hub by default in production, turning a latent
gap into a live one: any identity able to reach the configured hub
could patch local mesh state, including queuing a spoofed delivery.

Real per-peer admission control is #156's own deliverable. Until
then, the two state-mutating message methods are dropped before they
ever reach onMessage; every other message type this session relays
was already inert on receipt (PeerLifecycle only reacts to these
two), so this closes the whole exposure through this path.
@Mearman

Mearman commented Sep 17, 2026

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@Mearman
Mearman merged commit 9db4d68 into main Sep 17, 2026
6 checks passed
@Mearman
Mearman deleted the feat/coordinator-gateway branch September 17, 2026 15:15
Mearman added a commit that referenced this pull request Sep 17, 2026
… tests

Deny-all by default (agent-comms#156) means the two transports here
no longer discover or message each other, or exercise the
state_sync/state_update filter, without each explicitly trusting the
other's device-id first. Strengthens the #169 security-finding test
along the way: it now proves the filter still applies even to a hub
peer this side has explicitly trusted, not merely an anonymous one.
Mearman added a commit that referenced this pull request Sep 17, 2026
… tests

Deny-all by default (agent-comms#156) means the two transports here
no longer discover or message each other, or exercise the
state_sync/state_update filter, without each explicitly trusting the
other's device-id first. Strengthens the #169 security-finding test
along the way: it now proves the filter still applies even to a hub
peer this side has explicitly trusted, not merely an anonymous one.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Coordinator holds the hub connection (gateway role)

1 participant