Skip to content
Pagr

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/security fallback.
  • 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, and pagr doctor downgrades 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                        ping

The 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.

Command verification order
#CheckRejects
1Schema — a closed enum of command types, zod-validated payloadsAnything not in the nine, and any malformed payload
2Known signing key — keyId must be in the pinned server key setA gateway that does not hold a valid signing key
3Ed25519 signature over canonicalize(body)Any tampering with the command body
4Device bindingA command signed for a different Mac
5Expiry — never valid past 15 minutes, and not more than 2 minutes into the futureStale or clock-skewed commands
6Replay — nonce and command id in a bounded LRUA previously seen command, replayed
7IdempotencyA retry with the same key returns the original ack as duplicate and does not execute twice
8Local existenceA 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 0600 inside a 0700 directory;
  • 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.

Files under ~/.pagr
PathContents
config.jsonDevice id, user id, gateway URL, pinned server public keys, device name. No secrets.
projects.jsonProject id to local path. The only place paths live.
sessions.jsonSession id to provider session id.
replay.jsonRecently seen command nonces, for anti-replay.
policy.jsonThe approval policy synced from your dashboard settings.
logs/daemon.logLocal 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.sockThe local Unix socket. Not reachable over the network.
Keychaindev.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.