The bridge
How the bridge works
The bridge is the only part of Pagr that runs on your machine, which makes it the part an attacker would most like to control. This is how it is built so that even a compromised cloud cannot turn it into a remote shell.
One outbound connection#
The daemon opens a single WebSocket over TLS to the Pagr gateway and keeps it open. That is the entire network footprint. It sends a heartbeat every 20 seconds, answers the gateway's ping with pong, and reconnects with exponential backoff and jitter when the connection drops.
Because the connection is outbound, no inbound firewall rule, port forward or tunnel is needed — and none is possible. Corporate proxies that intercept WebSockets are the one common thing that breaks it.
The device key#
pagr connect generates an Ed25519 keypair locally with Node's crypto. Only the public half is ever transmitted, and only once, during pairing.
- The private key lives in the macOS Keychain, service
dev.pagr.bridge, via a native keyring binding with a/usr/bin/securityfallback. - There is no long-lived bearer token. Gateway authentication is challenge/response, so nothing replayable is stored or sent.
- A plaintext file store exists only behind
PAGR_INSECURE_FILE_STORE=1, for CI and headless machines. The daemon logs a warning whenever it is in use, andpagr doctordowngrades the secret-store check to a warning. - Revoking the device in the dashboard removes the public key server-side; the bridge is refused at its next connection attempt.
Connecting and authenticating#
Four frames, then normal traffic:
bridge → gateway gateway → bridge---------------- ----------------auth.request {deviceId} auth.challenge {nonce}auth.response {deviceId, nonce, signature, bridgeVersion, protocolVersion} auth.result {ok, error?, serverKeys?, minBridgeVersion?}event {event: DeviceEvent} command {envelope: CommandEnvelope}pong pingThe signature is base64url Ed25519 over the device id, a dot, and the nonce, as one literal string. On success the gateway returns serverKeys — a map of key id to raw public key — which the bridge pins in config.json and uses to verify every command it is later sent. Rotation works by shipping overlapping sets, so a key can be retired without a flag day.
After each successful authentication the bridge sends a device.hello carrying its version, the macOS version, agent status, project summaries and session summaries. Full detail in the protocol reference.
How a command is checked#
Every inbound command runs this gauntlet in order. The first failure rejects it with a typed command.ack and nothing is executed.
| # | Check | Rejects |
|---|---|---|
| 1 | Schema — a closed enum of command types, zod-validated payloads | Anything not in the nine, and any malformed payload |
| 2 | Known signing key — keyId must be in the pinned server key set | A gateway that does not hold a valid signing key |
| 3 | Ed25519 signature over canonicalize(body) | Any tampering with the command body |
| 4 | Device binding | A command signed for a different Mac |
| 5 | Expiry — never valid past 15 minutes, and not more than 2 minutes into the future | Stale or clock-skewed commands |
| 6 | Replay — nonce and command id in a bounded LRU | A previously seen command, replayed |
| 7 | Idempotency | A retry with the same key returns the original ack as duplicate and does not execute twice |
| 8 | Local existence | A project or session id that does not exist on this Mac |
canonicalize is JSON.stringify with recursively sorted keys, no whitespace and undefined dropped — so both sides sign byte-identical input.
No inbound surface#
The bridge never listens on a TCP port. Its only local endpoint is a Unix-domain socket used by the pagr CLI:
- created mode
0600inside a0700directory; - on start, a stale socket is unlinked only if it really is a socket owned by the current uid;
- ownership and mode are re-verified after bind;
- requests are newline-delimited JSON with a 1 MiB line cap, and unknown methods are refused.
The daemon also refuses to start a second instance against the same PAGR_HOME, which is what stops a foreground pagr daemon run fighting the launchd agent.
Where files live#
Everything local sits under ~/.pagr, mode 0700. This is also the complete list of what Pagr writes to your disk.
| Path | Contents |
|---|---|
config.json | Device id, user id, gateway URL, pinned server public keys, device name. No secrets. |
projects.json | Project id to local path. The only place paths live. |
sessions.json | Session id to provider session id. |
replay.json | Recently seen command nonces, for anti-replay. |
policy.json | The approval policy synced from your dashboard settings. |
logs/daemon.log | Local JSON log with your home directory rewritten to ~. Never uploaded. |
tmp/att_* | Downloaded images, mode 0600, deleted as soon as the agent has consumed them. |
run/daemon.sock | The local Unix socket. Not reachable over the network. |
| Keychain | dev.pagr.bridge / device.private_key — the Ed25519 private key. Never transmitted. |
There is no telemetry: no analytics, no crash reports, no usage metrics. The only outbound calls in the whole bridge are the gateway WebSocket, the two pairing endpoints, and attachment downloads.