Every agent run's network traffic leaves through an egress mode chosen for that session. The three modes are direct, frp, and client-proxy (implemented in packages/egress and governed by the EgressState state machine).
Egress is scoped to a session: the mode binds to the session, switching modes is an explicit lifecycle change, and failure never degrades silently.
Egress modes
| Mode | Transport | When to use |
|---|---|---|
direct |
The executing machine's existing route (no tunnel). | Machines with a public IP or otherwise reachable network. |
frp |
An frp v0.71.1 reverse tunnel (TCP proxy). | Enrolled devices behind NAT that need inbound reachability. |
client-proxy |
SOCKS5 through a selected client as the network exit. | Tools like browsers that should use a specific client's network. |
How a mode is selected
selectBestMode(hasPublicIp, hasFrpServer, hasRelayNode):
hasPublicIp → direct
hasFrpServer → frp
hasRelayNode → client-proxy
otherwise → direct (outbound always works; inbound via the enrollment channel)
The mode can also be pinned by configuration:
MAGICFROG_EGRESS_MODE=client_proxy # or "direct" (default when unset)
MAGICFROG_CLIENT_ID=<client id> # defaults to the computer id
MAGICFROG_FRPS_ADDR=127.0.0.1
MAGICFROG_FRPS_PORT=7000
MAGICFROG_FRPS_TOKEN=<token>
The tunnel endpoint is published as socks5://<frps host>:<remotePort> once healthy.
Fail-closed session scoping
The EgressState state machine has four states — direct, switching, client_proxy, failed — with these transitions:
| From | To | When |
|---|---|---|
direct |
switching |
An egress switch is requested, guarded by the target client-proxy being online and authorized. |
switching |
client_proxy |
The tunnel is established and the health check passed; browser session/context binds the new egress (explicit lifecycle change). |
switching |
failed |
Tunnel failure, client offline, or policy denial — no fallback to direct, the failure is explicit. |
client_proxy |
switching |
An explicit mode or client change; the old tunnel is destroyed before a new one is built, the old egress is never reused. |
client_proxy |
failed |
Tunnel down, grant revoked, or token expired — existing connections through the proxy are terminated. |
The failed state is absorbing: there is no in-place recovery edge. Re-establishing on a failed session is an explicit error — the only way back to direct is destroying and rebuilding the egress session (a new session number), never a silent in-place transition.
Browser session binding
Browser sessions bind the mode at creation. A client_proxy session launches the browser with --proxy-server=socks5://host:port (or http://), while an explicit direct session launches without a proxy argument — that is a configured mode, not a fallback. While switching or failed, the browser refuses to bind without a stable egress: bindings carry the tunnel epoch, and a stale binding is refused (isCurrent guard).
If the tunnel dies mid-session, the browser keeps its now-dead proxy address: Chromium fails connections, it never bypasses the proxy to direct on its own.
Switching egress
Switching is an explicit lifecycle change driven through PATCH /v1/runs/{runId}/egress:
- Request:
{ "mode": "direct" | "client_proxy", "clientId": "..." }—clientIdis required when mode isclient_proxy(400 otherwise). - Errors are typed and explicitly surfaced:
400missing clientId,403unauthorized,410client offline (fail-closed, no fallback),504tunnel establish deadline.
Guards are validated before leaving the current state: a rejected request leaves the healthy session untouched. Once a switch is committed, any failure lands in failed — never back in the old egress.
Revocation timing
Revoking a client's egress grant takes effect within 5 seconds: frpc is killed with SIGTERM first, SIGKILL after a 2-second grace, with a hard 5s bound — the egress machine enters failed, and every connection routed through that proxy is terminated. Existing connections are proactively terminated, not left to time out on their own.