Share a running terminal session with a paired device, watch it live and continue it from there. A shared terminal session keeps running in its terminal, which must stay open; the device is a remote control. A device can also start a new session, or reopen any saved one, without a terminal (see Sessions started from a device). macOS, Linux and Windows; see Windows for what differs there.
Three pieces are involved:
- each terminal session, which owns its session and applies every command;
- the remote gateway, a small host process that pairs devices, keeps the list of shared sessions and routes each device command to the terminal that owns the session. It never runs agent turns;
- the device (the Expo phone app in the
rustcode-mobilerepository), which speaks the remote protocol to the gateway over WebSocket.
The plan this implements is
Live terminal sessions from an iOS client.
The older rustcode serve transport in mobile.md is separate and
unchanged.
Plain LAN traffic is not encrypted. The gateway has no TLS. Devices are authenticated, but on an ordinary LAN anyone on that network can read and alter the traffic, including your prompts and the session output. NetBird is the recommended path: its tunnel encrypts the traffic between phone and Mac. Use a plain LAN address only on a network you trust. Never forward the port to the internet. The threat model, what each route protects and the comparison with direct TLS are in remote-security.md.
Connect the phone and Mac to the same trusted Wi-Fi or LAN. In any terminal session, run:
/remote
The gateway starts automatically and chooses this machine's Wi-Fi or Ethernet
address. /remote shows its address and a QR code in a full-height panel. Open
the app, tap Scan QR, and scan it. Every session open in a terminal on
this machine appears in the app's session list, and so does every session
opened later: /remote switches remote access on for the machine, not for one
session. No IP address needs to be typed. Address and code are available
as a fallback when the camera cannot scan.
The QR uses the Mac's address, not the router's address or its public internet
IP. A public IP alone cannot route through your router to the Mac. For access
away from home, connect both devices to NetBird and set this machine's NetBird
address once in the user config.toml:
[remote]
bind = "100.92.13.44" # this machine's NetBird address
# port = 17879 # optional
# advertise = "mac.netbird.cloud" # optional: the name devices should dialAlternatively, run rustcode remote serve --bind 100.92.13.44 first. Obtain
the address with netbird status. Ordinary same-LAN pairing needs no config.
The gateway runs in the background when started by /remote; output goes to
remote/gateway.log in the configuration directory. It keeps running when the
terminal exits; rustcode remote stop stops it.
Without --bind or a configured bind, auto chooses an active Wi-Fi/Ethernet
address to advertise, preferring private IPv4 LAN addresses over VPN addresses.
The OS route breaks ties between LAN interfaces. Virtual bridges and
link-local addresses are excluded.
The same plan also opens a listener on every other LAN address and on every
overlay address a tunnel interface has: a 100.64.0.0/10 address as NetBird,
Tailscale and ZeroTier hand out, and Tailscale's fd7a:115c:a1e0::/48. The
machine therefore answers at home on the LAN and away from home over the mesh,
without --bind or --advertise. Tunnel interfaces are recognised by name on
every OS (utun*, tun*, wg*, wt0, tailscale0, zt*, wintun, and the
Windows adapter names NetBird, Tailscale, WireGuard Tunnel,
ZeroTier One); a private address on one of them is an overlay address, and on
Wi-Fi or Ethernet it is the LAN. A 100.64.0.0/10 address on Wi-Fi or
Ethernet is a carrier's CGNAT address, not a mesh one: it is never a candidate,
never bound, and never reported as a route. --advertise still replaces the
address pairing details name.
To prefer the mesh instead (stable across Wi-Fi changes, reachable off-LAN),
set [remote] prefer_interface = "netbird" — or
rustcode remote serve --prefer-interface netbird. An auto bind then picks
the NetBird interface address first (wt-prefixed devices, or any interface
with netbird in its name) and falls back to the LAN default when NetBird is
down. On macOS NetBird uses a generic utun device shared with other VPNs,
so there prefer --advertise <host>.netbird.cloud instead.
With no usable network interface, it falls back to 127.0.0.1 and explains
that a phone cannot reach it. Explicit --bind 127.0.0.1 keeps local-only
access for a Simulator or test client.
The selection is repeated every five seconds while the gateway runs. When the
answer changes (DHCP handed out another address, the machine joined another
network, Wi-Fi came up after the gateway did, a mesh VPN came up or went down)
the listeners follow on the same port, without a restart: the primary listener
moves to the new LAN address and the extra LAN and overlay listeners open and
close with their interfaces. Pairing details, the QR code, remote status and
/remote status name the new address, shared sessions stay registered and
devices stay paired. A device that still dials the old address
needs the new one: on the same network the app finds it by itself from the
Bonjour announcement, without a code; otherwise a fresh QR code gives it. An explicit --bind <ip> is never moved.
For an address that does not change at all, give the gateway a stable name
instead of an IP: --advertise mac.netbird.cloud (or [remote] advertise)
with the NetBird address as --bind, or --bind 0.0.0.0 --advertise <name>
on a trusted network. Prefer an IPv4 address or such a name over the Mac's
.local name: .local also resolves to IPv6 and to other interfaces (VM
bridges, VPNs) where the gateway does not listen. Bonjour announces the
service name, port, protocol version and the gateway's identifier, never
pairing material. The identifier lets a paired device recognise this host at
a new address and reconnect there with the token it already holds.
rustcode remote status and /remote status list the machine's current
addresses next to the listen and advertised address, and say whether the bind
is auto (follows address changes) or fixed and whether the advertised host
was given with --advertise. When the listener sits on an address the machine
no longer has, or a fixed advertised IP is gone, they warn and print the
command that recovers it:
warning 192.168.31.237 is no longer an address of this machine and the bind is fixed: no device can reach the gateway
recover `rustcode remote stop && rustcode remote serve --bind auto` (follows address changes from now on)
or `--bind 192.168.31.185` for the address it has now; a `[remote] bind` in config.toml needs the same change
With an auto bind the warning only says the gateway is about to move.
To choose another address by hand: rustcode remote stop, then
rustcode remote serve with an explicit --bind. Sessions that were shared
register with the new gateway on their own within two minutes; devices stay
paired.
Settings › Remote access is the same switch, without a terminal. Turn it
on and every thread this computer has is listed for paired devices — across
all projects, from the thread store, not only the ones the app has open. Every
open thread is shared live as well, on the built-in harness and on Codex, Claude
Code, Pi, OpenCode or any other external harness (harnesses); a
thread opened later is shared as soon as it exists, and closing it ends its
live share. Opening a thread that is not open reopens it the way a device
reopens a saved one (see Saved sessions). Switching it off
stops the gateway and every device, exactly as /remote off does; pairings are
kept.
| In the app | What it does |
|---|---|
| Remote access › Network access | On: starts the gateway and lets paired devices reach every thread on this computer, and installs the RustCode server as a background service first if it is not installed (after a confirmation: it stays up when the app closes and starts when you log in, see server). Off: stops the gateway. |
| Remote access › RustCode server | Whether the RustCode server is installed to run in the background (Installed · Running), with Install when it is not. Installed, the shared engine stays up when the app closes and starts when you log in; the threads this app starts are still its own engine's and end with it, until the app is switched to the server. |
| Remote access › Gateway address | The host:port a device dials, how many devices are connected, and whether it is reachable from another machine at all. |
| Remote access › Pair a device | Calls the same pairing challenge as /remote pair: a QR code, the address, the manual code, the countdown and the wrong codes left. Remote access is switched on first if it was off. |
| Pairing | The challenge on screen, its countdown, the wrong codes left, a lockout, and Paired ‹name› once a device pairs. |
| A phone asking to connect | A card in the window's corner, over any page: ‹name› wants to connect, the platform and address, Deny and Accept. Accept shows the code to enter on the phone (large, with Copy and its countdown) until the phone pairs or the code runs out. See Connection requests. |
| Remote access › Asking to connect | The same requests with the same answers, while any are open. |
| Paired devices | How many devices are paired, with their names and when each was last seen. A connected device also shows the route it arrived over and whether that route is encrypted (LAN · Unencrypted, NetBird · Encrypted, VPN · Encrypted, This computer), judged from the address it dialled. Revoke forgets one and disconnects it at once, as rustcode remote revoke does. |
| Remote access › Refused connections | Devices the gateway turned away, newest first, for a quarter of an hour after each stopped trying: an app that speaks another remote protocol version, a pairing code that did not work, an unknown or revoked pairing, or an app that was refused before it could say anything. Each says what happened and what to do, so a phone whose only message is that it cannot connect is answered here. See Refused connections. |
| Routes | The addresses the phone can dial this computer at, best first: each LAN address it listens on, each overlay address (NetBird, Tailscale or ZeroTier, with its provider when the interface name says) and the machine's .local name. The handshake reports the same list, so a phone learns it and keeps using the first route that answers. |
| A thread's header chip | Shown while a thread is shared; it carries the number of devices attached to that thread. |
Each shared thread tells a device which harness runs it and its model
(harness and model on the session list and on every snapshot), so a phone
labels a Claude Code thread as such. The app and a terminal share one setting:
/remote on in a terminal shares the app's open threads too, and the app's
switch shows that it is on. While a
thread runs on an external harness, a prompt sent from a device is run through
the harness exactly as the app's own message is, and the engine echoes it to
the app as a user_message_chunk, so the chat shows it live; cancelling,
steering, answering a question and resolving an approval work the same way.
One rough edge: an approval a device resolves leaves the app's approval card
on screen until the turn ends, because ACP has no way to withdraw a request
already sent to the client.
A host streams at most 32 live shares at once, the threads used most recently.
That bound limits streaming, not what a device sees: every thread on the
machine is in the paged list_saved_sessions list, newest first, with its
project, title, last write and state, and a device opens one that is not
shared with resume_session (see Saved sessions). Prompts a
device queues on a thread are dropped when sharing ends, and a share the
gateway turns down is retried no more than once a minute.
The ACP methods behind the page (_rustcode/remote, /start, /pair,
/stop, /answer, /revoke, in engine/src/acp/remote.rs) take an
optional sessionId (the first four): with it
they describe and drive that thread's share, without it the machine's own
switch, gateway and challenge. Their answers add qrRows (the QR code as rows
of 0 and 1, quiet zone included), target (the harness a shared thread
runs on), notShared (why this thread is held back on a thread's answer, the
count of held-back threads on the machine's) and, for the machine,
gateway.pairing, gateway.connectedDevices and gateway.routes (each
route's address, kind lan, overlay, hostname or public, and the
provider of an overlay address) from the gateway's own status, plus devices (each paired device's id, name, kind phone,
desktop or cli, platform, hostId, lastSeenAt and, on the machine's
answer, connected and, while connected, routes: one entry per open
connection with path loopback, lan, overlay or other, the overlay's
provider, the localAddress it dialled and encrypted, true only for an
overlay), connectRequests (open: id, deviceName,
deviceKind, platform,
remoteAddr, ageSecs, expiresInSecs, state pending or approved) and
endedConnectRequests (the last eight: id, deviceName, end, deviceId
when it paired, endedSecsAgo) and refusedConnections (the last eight
attempts the gateway turned away, newest first: reason, attempts,
deviceName, deviceKind, platform, protocolVersion,
supportedVersions, remoteAddr and refusedSecsAgo). _rustcode/remote/answer { id, accept }
answers with the machine's status plus connectAnswer (code,
expiresInSecs and instanceId on acceptance) or connectError;
_rustcode/remote/revoke { deviceId } with revoked or revokeError. The
app reads the machine's status every 2.5 s while the gateway runs, and every
15 s otherwise, whichever page is open. The wire protocol to the gateway
(remote-protocol) changed only additively:
session entries gained an optional harness, and every other field and frame
works as before.
| Command | Behavior |
|---|---|
/remote (/remote on) |
Switch remote access on for this machine. Finds the running gateway or starts one and shares the session on screen; every other terminal shares its session within a couple of seconds, and so does every terminal started later. While no device is paired it shows the gateway address, a QR code and a manual pairing code; afterwards it shows the status. |
/remote pair |
Same, and always shows a fresh pairing challenge, to add another device. The panel counts the challenge down and shows the wrong codes left, a lockout, and how it ended (see Pairing). |
/remote status |
Whether this session is shared, the gateway address, the devices attached to this session and the devices connected to the gateway, followed by everything rustcode remote status shows (addresses, paired devices, pairing state, shared sessions with this terminal's marked, log paths) once the gateway answers. Names only, never a credential. |
/remote off |
Switch remote access off for this machine: the gateway stops, every session disappears from every device, and nothing is shared until /remote is run again. Pairings are kept; running turns are not affected. |
The switch is remembered across restarts. While it is on, each interactive
terminal shares the session on its screen by itself: at start, and again under
a new registration after switching, resuming, forking or starting a session.
There is no per-session opt-out; a paired device can read and drive every
session that is open in a terminal. Headless runs (-p) are not shared.
A second terminal that tries to share a session another terminal already shares is refused; the first one keeps it.
What a device can do with a shared session: read it, submit a prompt when it
is idle, steer or queue while it runs, cancel the running turn, answer a
question, approve or deny an approval batch, and run session slash commands
such as /yolo. What it cannot do: switch sessions, or read anything that is
not part of a shared session. The terminal's own draft, cursor and
attachments are never touched.
Every tool batch that needs confirmation waits for a tap on the device, which
is slow over intermittent links. /yolo on (also from the device) enables
full auto-approve for the session: batches run without asking. /yolo off
restores per-batch approval. The mode is per session: it is recorded with the
session (also when --yolo or a terminal toggle sets it), so the session owner
that serves it after a gateway restart or upgrade starts the same way. It never
changes your config. A fresh session starts asking unless yolo = true is set
in the user config.toml (see configuration).
Plan mode still blocks mutating tools either way. Prefer /yolo on only on
sessions whose workspace you trust, and turn it back off when leaving the
device unattended.
A device can also ask a terminal to share a session it has open but never
shared (request_share with the session id). The terminal announces its open
session to a running gateway on its own; announcing shares nothing. The
terminal shows Device '<name>' asks to share this session. Press y to share it, n to decline. Answering y runs /remote there and resolves the request
as accepted — the device attaches with a fresh epoch. No answer within two
minutes, a decline, or the terminal going away resolves it as denied, which
get_request_status on the request id also reports. Any paired device can
ask. This applies only while a terminal's session is not shared, which with
remote access on is the short moment before it has registered.
| Command | Behavior |
|---|---|
rustcode remote serve [--bind <ip>] [--port <port>] [--advertise <host>] [--prefer-interface <name>] |
Run the gateway in the foreground until Ctrl-C, SIGTERM or remote stop. |
rustcode remote on |
Enable the persistent background gateway (alias of start). |
rustcode remote off |
Stop the running gateway (alias of stop). |
rustcode remote pair |
Ask the running gateway for a pairing challenge and show it: QR code, address, code and expiry time. In a terminal it then counts the challenge down until a device pairs or it ends (Ctrl-C leaves earlier); otherwise it prints and exits. |
rustcode remote devices allow <id> / deny <id> |
Grant full ACP control, or deny it and immediately disconnect the device. |
rustcode remote devices [--json] |
List paired devices: identifier, kind (phone, desktop or cli), platform, how long ago each was last seen, pairing time, expiry and name. JSON includes the host-controlled acp grant. Never a token. |
rustcode remote revoke <device> |
Revoke by identifier, identifier prefix (four characters or more) or unique name. |
rustcode remote status |
The running gateway: every address the WebSocket listener is bound to, one line each with its interface and the kind of network it is on (so an auto bind shows its LAN and its overlay addresses), and the advertised address with whether each follows address changes; who started it (the RustCode server, the app or a terminal), the machine's current addresses with a recovery command when the gateway's address is gone, paired devices and which are connected (with the network each arrived over and whether it is encrypted), the state of pairing (time and wrong codes left, or the lockout), devices the gateway turned away recently with the reason, shared sessions and who is attached to each, session owners that run without being shared, and the paths of the gateway log and the session logs. |
rustcode remote stop |
Stop the running gateway. |
One gateway runs per configuration directory. A second remote serve is
refused while the first holds its lock; a gateway that died is detected by
its process birth time and its files are reclaimed. remote start adopts a
gateway that already runs — the RustCode server's, for instance — instead of
starting a second one.
--bind (or [remote] bind) is where the WebSocket listener accepts
connections: auto by default, port 17879. --advertise is the host
that pairing details tell a device to dial; it takes an IP address or host
name and uses the listener's port.
- A loopback or specific bind advertises itself unless
--advertiseis given. autoselects the LAN-first address to advertise and opens the other LAN and overlay listeners, as described above;bind = "auto"also works in the user configuration.0.0.0.0and::are never advertised. Binding a wildcard requires--advertiseunless exactly one interface address is a candidate; the error lists the candidates.- The pairing details and the QR code carry the listener's other addresses as
alternates, so a device that cannot reach the advertised address (say, a mesh address on a phone without the mesh VPN) tries the LAN instead: private IPv4 on Wi-Fi/Ethernet first, then private and mesh addresses on tunnel interfaces (100.64.0.0/10 included), then global IPv6, at most four. The advertised address, loopback, link-local and container/VM bridges are left out. A wildcard bind offers every candidate of its family; anautobind offers the addresses it opened; a specific bind offers none. - The
[remote]section is read from the user configuration only. A project's.rustcode/config.tomlcannot choose the interface your sessions are exposed on.
After authentication the gateway reports every address it answers on
(routes on paired and authenticated), best first: the advertised
address, each LAN address, each overlay address on a tunnel interface, and the
machine's .local name. Each is { "address": "host:port", "kind": … }, where
kind is one of
| Kind | What it is |
|---|---|
lan |
a private IPv4 address on Wi-Fi or Ethernet |
overlay |
an overlay or VPN address, with provider netbird, tailscale or zerotier when the interface name says which; absent on macOS's shared utunN |
hostname |
a name, such as the machine's .local name |
public |
a public address or name; a client refuses to send a token over one until there is encrypted transport (#2651) |
Only addresses the gateway is actually listening on are reported: an address
it does not answer on is not a route. A device adds them to the routes it
keeps for this host, so pairing once is enough to reach it on another network.
The same list is in rustcode remote status and Settings › Remote access; the
pairing QR code's alternates stay compatible with it, and with an auto bind
they name the LAN and overlay addresses a device may dial instead.
A pairing challenge (from /remote or rustcode remote pair) has two
representations:
- a QR code. It encodes compact JSON with
protocol_version,address,gateway_id, a 256-bitcredential, the host's name and, on a wildcard bind,alternates(see Addresses); - the advertised address plus an eight-digit code, for typing by hand.
The code is drawn with half-block characters in explicit black on white with a
four-module quiet zone, so it scans in light and dark terminal themes. If the
terminal is too narrow for it the panel says so; use the address and code, or
rustcode remote pair in a wider window. When output is not a terminal,
rustcode remote pair prints the payload as text instead.
A challenge lasts two minutes and is single use: pairing with either representation destroys both. It tolerates five wrong attempts, shared by the code and the credential, and a new challenge replaces the previous one. Ten failed attempts within five minutes, counted across all connections and challenges, lock pairing for the rest of that window; while locked, even a correct code is refused. A peer on the network can therefore block pairing for a while, but cannot buy more guesses by reconnecting.
The pairing details state the lifetime, the wall-clock time the challenge expires and the number of wrong codes allowed. While they are on screen a line above them counts down, once a second, the time and the wrong codes left, and says when pairing is locked and for how long:
Waiting for a device: expires in 1:27, 4 wrong codes left.
Pairing is locked for 4:10 after too many wrong codes on this host: even the right code is refused until then. This code expires in 40 s.
When the challenge ends the line says how, and the QR code is taken down:
Paired "Lars's iPhone"., used up (five wrong codes), expired, replaced by a
newer challenge, or gone because the gateway stopped or restarted. In the
/remote panel this runs until the panel is closed or replaced;
rustcode remote pair in a terminal keeps running until the challenge ends
(Ctrl-C leaves earlier, the challenge stays valid). When output is not a
terminal, rustcode remote pair prints the details once and exits. If pairing
is already locked when the challenge is issued, the details say so first.
rustcode remote status shows the same numbers at any time.
A code that runs out is not always a wrong code. The gateway notes whether any
socket reached it while the latest code was showing (pairing.last_challenge_reached
in status, pairing.lastChallengeReached over ACP). When none did, the desktop
and rustcode remote pair say that no device connected, and that the phone must
be on the same network as the address shown (or reach it over NetBird or a VPN):
the failure is the route, so no refusal could be recorded for it. An expired or
used-up challenge is replaced by running /remote pair or
rustcode remote pair again.
A paired device receives a random token once. The host stores only its
SHA-256 digest in remote/devices.json under the configuration directory
(directory 0700, file 0600, replaced atomically). Device names are
labels chosen by the device, not identities. Pairing gives a device access to
every session this host shares, now and later, until it is revoked or the
pairing expires.
A device that asks for token_rotation in its pair frame has no expiry: the
pairing lasts until it is revoked. (The gateway takes the capability today;
the phone app's client change belongs to its own repository.) It holds a refresh
token, which every reconnect replaces, and a 15-minute access token that
reconnects without replacing anything. Only digests are stored, and the
access token's digest stays in memory, so a gateway restart asks for a
refresh. Presenting a refresh token the host has already replaced revokes the
whole pairing: the device's connections close as revoked with
token_replayed, the record is removed as if revoked, and the gateway logs
remote.token_replayed for the owner. The removal is written before anything
closes; if the device store cannot be written, the revocation is noted in
remote/revocations.json, retried, and the device is refused meanwhile. That
note is applied before the gateway serves anyone: if it cannot be read the
gateway refuses to start and names it, rather than let a revoked family back
in; repair it, or delete it after rustcode remote revoke. Pair again if that
was you.
Until the device acknowledges the new token, presenting the previous one again
is a retry that gets the same tokens back, so a lost acknowledgement never
locks it out and a copy of the previous token cannot rotate the device's token
away; after a gateway restart such a retry is a replay. A client acknowledges a
rotation once it has stored the new token; the phone app's client change
belongs to its repository. The host recognizes the 1024 tokens a family
retired most recently as replays; an older one is refused like a wrong token,
so no number of rotations ends a pairing.
A device that does not ask for rotation keeps the static token, and its
pairing lasts 30 days. Within them the device reconnects on its own, with no
code. After them its token is refused like an unknown one and the device
pairs again; a connection that is open at that moment stays open until it
drops. rustcode remote devices shows when each pairing expires, or until revoked for a device that rotates its tokens. An expired device is forgotten
when the next one pairs and does not count against the device limit. Pairings
made before this limit existed are counted from the day they were made.
Pairing the same app install again replaces its device record instead of
adding one: the app sends a random install identifier that it keeps, and the
host drops the earlier record and its token. A connection still open with the
old token is closed as revoked. Apps that do not send an identifier, and
records paired before the host kept one, add a record each time; those
duplicates are not merged. They expire 30 days after they were paired, or can
be revoked earlier by identifier. rustcode remote devices and
rustcode remote status show when each device last paired or connected
(unknown for records stored before this was kept), which tells the stale
ones from the one in use.
Instead of racing a two-minute QR code, a phone can ask. It finds the Mac on
the local network (Bonjour _rustcode._tcp, whose record lists
caps=connect_requests) and taps Connect; the gateway holds the request
and the desktop app shows ‹name› wants to connect wherever the user is.
- The phone opens a WebSocket and sends
connect_requestwith its name, platform and install identifier. It carries no secret. A desktop app on another machine asks the same way withdevice_kind: "desktop"and its machine's identifier (the record listscaps=connect_requests,desktops), and is shown and listed as, for example, Windows desktop. - The Mac shows the request. Deny ends it, and that address cannot ask again for 30 seconds. Nobody answering ends it after two minutes.
- Accept issues a pairing challenge bound to that request: it replaces any outstanding challenge (a QR code on screen, or another accepted request), and the Mac shows its eight-digit code.
- The user types or pastes the code on the phone, which sends it on the same
connection as an ordinary
pairframe. Only that connection can redeem it, and it may try once: a wrong code ends the request, and the phone asks again. Wrong codes count toward the host-wide lockout like any other. - The phone is paired as with a QR code, including the install identifier that replaces an earlier record of the same app.
At most four requests are open on a host and one per network address, so a
peer cannot flood the owner with prompts; a phone that reconnects with the
same install identifier replaces its own open request. Pressing Pair a
device while a code is on screen for a request replaces that code too.
rustcode remote status lists open requests. The frames and their order are
in the protocol.
A device that does not get in is refused with one error frame and the
connection closes (see the protocol).
The phone shows what its app makes of that code, which leaves the host's owner
looking at a phone that only says it cannot connect. So the gateway also keeps
the refusals it can help with, newest first, for fifteen minutes after each
stopped trying: eight at most, with repeats of the same attempt from the same
address and app counted rather than listed again.
| Recorded as | What happened | What the owner can do |
|---|---|---|
unsupported_version |
The app speaks a remote protocol version this host does not. | Update RustCode on the device or on this computer so both match. The answer to the device says the same, with supported_versions. |
pairing_failed |
The pairing code or QR credential was wrong, already used, or had expired. | Show a fresh code with Pair a device. |
rate_limited |
Too many wrong codes on this host, so even the right one is refused until the lockout ends. | Wait for the lockout, then show a fresh code. |
unauthorized |
A pairing this host does not accept: unknown, revoked or expired. | Pair the device again. |
token_replayed |
A retired refresh token was presented, so the whole pairing was removed. | Pair the device again. |
unexpected_origin |
The WebSocket upgrade carried an Origin no native app sends, so it was refused before a frame arrived. |
That is a RustCode app from before it sent the native app origin, or a web page trying the port: update the app. Nothing was shared either way. |
secure_connection |
The device opened a TLS connection (wss://, https://) to the gateway, which speaks plain WebSocket, so its first bytes could not be read. |
Update the app on the device, or enter the address without https:// or wss://. |
unrecognized_request |
The device reached the gateway and sent JSON text, but its first frame is not a pair, authenticate or connect_request this host reads: an app build whose handshake differs. |
Update RustCode on the device and on this computer so both are the latest. |
Settings › Remote access lists them under Refused connections, with the
device's own label when its frame named one, its kind and platform, the
address it came from, how many tries and how long ago. rustcode remote status
prints the same lines, and refusedConnections carries them to any ACP client.
Nothing that is only a bug on the device (a binary frame, an oversized one, a
handshake that never arrives) is recorded, so it cannot crowd out the reasons a person can fix.
Secrets never reach the record: only the label, kind, platform, protocol
version and address a refused frame named are read from it.
A computer can also be the client: rustcode machines pairs this one with
another computer's gateway and keeps it, so the same machine is one entry with
every address it answers on. This is the command-line side of the desktop app's
multi-machine client (rustcode/client, the rustcode-client crate); the app
fills in the same saved machines.
| Command | Behavior |
|---|---|
rustcode machines code [--link] [--json] [--cancel] |
Create a connect code on this computer for another one to pair with: the short code, the address it carries, the long link and what to run on the other computer. --link prints only the link, for a script; --cancel withdraws the open code. Switches remote access on when it is off, as rustcode remote pair does. |
rustcode machines connect <code> [--address <host:port>] [--name <name>] |
Pair with a connect code created on another computer, with no accept step there: stores the token and saves the machine. --address is where to reach it when the short code carries no address, or instead of the one it carries. rustcode machines add <code> does the same. Over SSH in one line: rustcode machines connect "$(ssh box rustcode machines code --link)". |
rustcode machines add <address> [--name <name>] [--code <digits>] |
Ask the machine to let this one in; the code is exactly the eight digits it shows (spaces and dashes are ignored). It shows ‹name› wants to connect on that machine; accept it there, then type the eight-digit code it shows (or pass --code). Prints Waiting for … to accept… while the request is open. Pairs, stores the token and saves the machine. |
rustcode machines add --discover |
The same, choosing the machine from Bonjour when exactly one answers. |
rustcode machines list [--threads] [--json] |
Name, platform, the route in use, the network it runs over with whether that is encrypted (LAN (not encrypted), NetBird (encrypted by the tunnel); — while the machine is not connected) and whether the machine answers now. Under a machine that is reachable one way and not another, it prints the hint: e.g. Not reachable over NetBird (100.90.16.237:17879). On that machine, check that NetBird allows inbound connections and that an access policy allows TCP 17879 between the two machines. The platform comes from the gateway (macos, windows or linux) and is refreshed whenever the machine is reached; a gateway older than that reports none, and the row keeps unknown. With --threads, each connected machine's thread titles over ACP (session/list), printed under the machine's row and after its hint. --json for scripts, with the same hint under hint and the network under network (path, provider, encrypted, label). |
rustcode machines remove <name-or-id> |
Forget a machine and delete its token. It still lists this computer until it is revoked there. |
rustcode machines discover [--secs <n>] [--json] |
Machines on this network whose record lists caps=desktops: name, address and machine id. |
An address is host, host:port, [v6]:port or ws://host:port; the port
defaults to 17879. wss:// is refused: the gateway has no TLS listener.
A machine token travels unencrypted on the LAN, exactly like the phone app's: the gateway has no TLS, so anything on that network can read a prompt, a thread and — during pairing — the code. Use a network you trust, or put both machines on Tailscale or NetBird, whose tunnel encrypts the traffic. Because a public address has no trusted network around it, this client refuses to pair over one, or to send a saved machine's token to one, and says so instead; that holds until encrypted transport (#2651) lands.
- The command line pairs as a desktop client, because that is the pairing
the machine's owner grants ACP access to when accepting the request. A
cli-kind device reaching the same code is allowed the session protocol until the owner runsrustcode remote devices allow <device>there. - Saved machines live in
machines.jsonin the configuration directory (0600on Unix). Each is keyed by the machine's stablehost_id— its gateway identifier — with an ordered list of routes (lan,vpn,tailscale,public), the device record, the platform and the RustCode version it last reported, and when it was added and last connected. The platform and the version are optional fields of thepairedandauthenticatedframes (docs/remote-protocol); an older gateway sends neither, so the row keepsunknownfor the platform and says nothing about a version rather than guessing one. A route keeps the product an overlay address is on (netbird,zerotier; a Tailscale address is its own kind and provider). Every machine is a peer; amainfield in a file written by an older release is read and ignored. A change takesmachines.locknext to the file and re-reads it first, so tworustcode machinesruns cannot drop each other's machine. - Tokens are kept in the operating system credential store (Keychain,
Credential Manager, Secret Service) under
rustcode-machines, one entry per machine and profile: the account names this configuration directory's install id and the machine'shost_id, so twoRUSTCODE_CONFIG_DIRprofiles never overwrite each other's token. On macOS the service isrustcode-machine-tokens, and Apple's/usr/bin/securitywrites and reads the entries: a keychain item trusts the program that created it, pinned to that build's hash when the signer has no Developer ID team, so items RustCode created itself asked for the login keychain password after every update, in every development build, and once each for the app and the CLI. An itemsecuritycreated trustssecurity, which stays the same across RustCode updates and binaries. An entry an earlier release wrote underrustcode-machinesis read once in-process (the last prompt; "Allow" is enough), written again throughsecurity, and the old item deleted. They are never written tocredentials.jsonorprovider-accounts.json. On a machine without a credential store, setRUSTCODE_MACHINE_TOKEN_STORE=fileto keep them inmachines/tokens.json(0600in a0700directory) instead; without it, the command says so rather than writing a credential to disk silently. - The store is opened before a request is sent, off the runtime and with a
deadline (
rustcode_client::tokens::bounded, ten seconds). It is reached over the operating system's own IPC, where a keychain unlock or a Secret Service prompt can block for as long as nobody answers; a store that does not answer stops the pairing at this end, with the message naming the file fallback, instead of doing so after the other machine has accepted the request and enrolled this computer (#3325). The token write after the code is bounded the same way. - Routes are raced, not walked in order. Every LAN route starts at once; after
one second the overlay routes start too; the first connection that
authenticates as the machine we saved — same
host_id, and the device record the token belongs to — wins, and the rest are cancelled. The whole race ends after five seconds. An address that now belongs to another computer is refused rather than trusted. - Identity before credentials. Every socket asks who answers before a
token goes on it: the first frame is
describe, the gateway answersdescribedwith itsgateway_id, name, version and capabilities, and the same socket then takesauthenticate. A route whose gateway names another machine is dropped with the token unsent. Once a machine has answereddescribethe client remembers it (describesinmachines.json) and from then on drops any address that cannot answer it — an older RustCode on a saved address that now belongs to someone else gets nothing. A machine whose RustCode predatesdescribekeeps the old handshake until it updates. A refusal (unauthorized,revoked) counts as Needs pairing only when it came from a socket that proved to be the machine, so a stranger on a stale LAN address can no longer stop the retries; the app reads that from the client's typed errors (PairingLost,Unreachable), never from the message text. Because a connection that drops is re-raced, the client moves to a route that works again (a LAN after returning home, an overlay after leaving it) on the next attempt. - The addresses a machine reports after authentication are learned and saved: a new LAN address goes before the overlay routes, a new overlay address after them, and an address already saved only updates its kind and product. Pairing once is therefore enough to reach a machine on a network it was not paired on.
- The app's Add sheet lists more than Bonjour: a saved machine's overlay
address, learned from its handshake, is listed too, so a machine away from
home is one click rather than a typed IP. The two sources are merged by host
id, one row per machine, and an address found that way never needs typing.
rustcode machines discoveritself stays Bonjour only, since it is a one-shot browse with no saved machines behind it. - A hint is shown when one kind of route answers and another does not, in
machines listand in the app's machine status: an overlay address that fails while the LAN works names the product and what to check there (and a LAN that fails while the overlay works is expected away from home). The overlay probe is a plain TCP connect after the LAN wins — it never carries a token — so the diagnosis costs nothing when there is no overlay route. - An overlay route earns no hint when this computer is not on that overlay, and none when another overlay route to the same machine answers (#3421). A machine that runs Radmin VPN as well as NetBird reports both; on a computer with only NetBird there is no interface in Radmin's address space, so its route can never answer, and telling the owner to check Radmin's firewall would send them to a network they are not on. Those routes are still kept and dialled, but only after every route that can answer.
Pairing is trust between two computers. On top of it, paired computers share metadata about themselves and about each other, so a name chosen on one computer shows on all of them and every computer can see the whole fleet, not only the computers it paired with directly. Nothing here grants access: a computer that is only known through a peer has no address, token or route saved, and is paired on its own (Pair in Settings › Environments).
- What travels: a computer's id (its gateway id), its name and the time that name was chosen, its platform, its RustCode version and when some computer last had word from it. Nothing else.
- How:
_rustcode/environment/sync, an ACP request over the connection a pairing already opened (engine/src/acp/environment.rs; it is on the gateway's relay allowlist). The caller sends itself and everything it knows; the other computer merges that and answers with itself and everything it knows. The desktop app runs it against its own engine and every connected environment when a connection comes up, when a name changes and every minute (apps/desktop/src/state/fleet.ts). - Merge: last writer wins per computer (
engine/src/fleet.rs). A name is a pair of name and chosen at; the later time wins, and on a tie the greater name does, so every computer ends on the same one. A name nobody chose (the operating system's) has no time and loses to any rename. Platform, version and last-seen come from the more recent report. An entry about a computer that was never reported on for 90 days is forgotten. - Storage: a computer's own name is
environment.jsonin the configuration directory (without it the gateway announces the operating system's name; a gateway reads it when it starts, so a new pairing sees the new name). What it learned of the others isfleet.json. A paired machine's name is itsdisplay_namewithname_updated_atinmachines.json. - Rename: renaming an environment stamps the name with the time. The next sync sends it to that computer, which adopts it as its own name when it is newer than the one it has, and from there it spreads to every computer that syncs with either. A computer that was offline keeps its old name until it syncs; the first sync after it reconnects brings the newest one. Renaming this computer announces it the same way, to every connected environment.
- Known through another: a computer this one is not paired with is listed read-only, with who told us and when it was last seen. The list is only as fresh as that last report, so its row says last seen, never online.
- Older RustCode: a computer that does not know the request answers with an error, which the app ignores; it simply does not take part until it is updated.
A connect code pairs two computers by carrying one string from one to the other: no request to accept, and no address to look up when the code carries it.
- On the computer to be reached: Settings › Environments › Add environment › Connect code › Create connect code
(or
rustcode machines code). It showsK7MQ-29XH-TP4W-NRD6with Copy and Copy link, the address the code carries, and a countdown with Waiting for a computer…, which becomes Paired ‹name›. - On the other computer: Settings › Environments › Add environment › Connect code › I have a code, paste the code or the link, Connect (or
rustcode machines connect <code>). It is paired, its token stored, and the first computer's threads appear.
| Short code | Link | |
|---|---|---|
| Looks like | K7MQ-29XH-TP4W-NRD6 |
rustcode://connect/<base64url JSON> |
| Carries | one IPv4 address on port 17879 (an overlay address first, else the LAN), or none, and a 44-bit secret | the gateway's id and name, every route it answers on, a 256-bit credential |
| Use | read aloud, typed by hand | pasted, over SSH, in a script |
- A code works once, for ten minutes, and five wrong tries destroy it; the
host-wide pairing lockout (ten failures in five minutes) applies as well.
Creating a new code replaces the open one; Cancel code (or
rustcode machines code --cancel) withdraws it.rustcode remote statuslists an open code. - It lives in a slot of its own next to the phone challenge: showing a QR code, accepting a computer's connection request and creating a connect code never replace each other's code.
- Anyone who has the code can see and run all threads on that computer: it
pairs a
desktopdevice with ACP access, because creating the code is the owner's consent. The device it creates is listed under Settings › Remote access › Devices andrustcode remote devices, and removed there like any other. - The short code has the alphabet
23456789ABCDEFGHJKLMNPQRSTUVWXYZ(no0,1,I,O); case, spaces and dashes do not matter. When the gateway listens on another port or has no IPv4 address, the short code carries none and the address it shows goes next to it (--address). - The redeeming side races the code's routes with
describe, sends the secret once on the first that answers as a gateway that takes connect codes (and, for the link, as the gateway the link names), and saves the machine. A machine with an older RustCode answers update RustCode there. - On the wire it is
pairwithmethod: "machine"; a gateway listsmachine_codesin itsdescribedcapabilities (docs/remote-protocol/v1). - Like every pairing, the code crosses a LAN in clear text; an overlay (NetBird, Tailscale) encrypts it.
rustcode up makes the computer it runs on one another can pair with:
- installs (or repairs) the RustCode server as a background service from
login, as
rustcode server installdoes, and restarts a server of an older version when no turn is running (--yesrestarts it anyway). On Linux it triesloginctl enable-lingerwithout sudo first; where that needs root, the result says thesudocommand to run. Where no service can be installed (a container with no systemd user session), or with--no-service, the server runs in the background until the computer restarts, and the result says so; - switches remote access on and waits for the gateway. With a server
running, the server starts the gateway itself (
remote/start-requested, whichrustcode remote stopoverrides): on Windows a command run over SSH is in the SSH logon session, and a gateway started there would be out of reach of the desktop and leave files only an administrator can open; - creates a connect code and prints it, with the command to type on the other computer.
--json prints {version, host_name, os, server, gateway, routes, connect_code, problems} and keeps progress on standard error, for a script
or machines add-ssh. Running it again is safe: an installed service is left
as it is, a running gateway is reused, and a new code replaces the open one.
For a computer this one already reaches with ssh (a Linux box, a VPS, a Mac,
a Windows PC with OpenSSH), one step makes it a paired machine: Settings ›
Machines › Connect over SSH, type lars@box or an alias from
~/.ssh/config, Connect; or
rustcode machines add-ssh lars@box [--port 2222] [--identity ~/.ssh/key] [--yes | --no-install] [--mutual | --one-way] [--json]By default this pairs one way: this computer can see and run threads on
box, and box cannot reach this computer. Choose both ways to let box
reach this one too (Mutual access).
| Step | What runs | Fails with |
|---|---|---|
| Signing in | ssh … exit 0 |
SSH could not sign in to box without asking for a password or passphrase… (ssh-add), an unknown or changed host key (connect once in a terminal), or box did not answer over SSH |
| Checking RustCode | a POSIX login shell (sh -l -s) reports uname and every rustcode with its version (~/.rustcode/bin too); with no POSIX shell, Windows PowerShell does |
RustCode has no build for Linux on riscv64, or no usable shell |
| Installing RustCode X.Y.Z | only when it is missing or older than this computer's: this release's install.sh --no-app --version X.Y.Z --bin-dir ~/.rustcode/bin (or install.ps1 -NoApp -Version X.Y.Z) sent over SSH's standard input, checked against the release SHA256SUMS as always. The app's checkbox, --yes or the terminal prompt allow it; --no-install and a run with no terminal refuse |
the installer's last line |
| Starting its server | rustcode up --yes --json there |
what up said |
| Pairing | the connect code from that JSON, redeemed here over the routes it lists | box is reachable over SSH, but not on RustCode's port (…). Put both computers on the same network or a VPN such as NetBird or Tailscale. The open code is withdrawn there |
- Every command uses this computer's own
ssh, its config and agent, withBatchMode=yes,ConnectTimeout=10,ServerAliveInterval=15,ServerAliveCountMax=3andControlMaster=no, so nothing waits for a prompt; each step has its own time limit (ten minutes for the install). Host key checking stays as configured. - The host is checked (no leading
-, no whitespace or shell characters) and passed after--. Scripts go on standard input or as an encoded PowerShell command; the only words put in a remote command line are the version and the path the remote reported, quoted for its shell. - The pairing secret travels inside SSH only. Afterwards the two talk over the gateway's routes like any paired machines, so a host reachable over SSH alone (a VPS with only a public address) cannot be paired yet: that needs SSH as a route of its own.
- Nothing is stopped or removed. To take the service off that computer:
ssh box rustcode server uninstall. - On Windows the server runs as the logon task in the user's desktop session,
so it starts once that user is signed in on the PC;
upover SSH asks that server to start the gateway rather than starting one in the SSH session. --jsonprints one line per step change ({"progress": {...}}), then{"connected": {...}}or{"error": {"step", "kind", "message"}}. In the mock, a host containingdeniedornovpnshows the two common failures.
Pairing grants access in one direction: the computer that redeems a connect
code can reach the computer that created it. Connect over SSH can grant the
other direction as well, in the same step, so nobody repeats an opaque pairing
by hand: Add environment › SSH › Both ways, or --mutual.
| Choice | This computer reaches box |
box reaches this computer |
|---|---|---|
| One way (default) | yes | no |
| Both ways | yes | yes |
- Consent. Choosing Both ways here is this computer's consent: it creates
a fresh connect code, which pairs
boxas adesktopdevice with ACP access, as any code does (Connect codes). The SSH sign-in thatboxalready accepted isbox's consent: its own user'srustcode machines connectredeems the code there and stores the token in its own credential store. In a terminal with neither flag,add-sshasks Let box reach this computer too?; with--jsonor no terminal the answer is one way. - How. After the one-way pairing is saved, a sixth step, Letting box reach
this computer (
pair_back), creates the code here and runsrustcode machines connect <link>over SSH onbox. The code lives ten minutes and works once; it is withdrawn when the step fails. - Two pairings. Each direction is its own pairing with its own token:
boxis a saved environment here, andboxis a paired device on this computer (under Settings › Remote access › Paired devices andrustcode remote devices). Ending one never ends the other: Forget on the environment (orrustcode machines remove) ends this computer's access tobox; Revoke on the device (orrustcode remote revoke) endsbox's access to this computer. Settings › Environments › ‹box› › Access shows both with their own button; a device counts asboxwhen itshostIdis the environment's id. - When it cannot. The one-way pairing is already saved and stays; the page
and
--json(mutual_error,access: "one_way") say why and what to do:boxis offline or SSH no longer signs in: the SSH sentence of that step (SSH could not sign in…, box did not answer over SSH).boxruns a RustCode too old to knowmachines connect: RustCode X on box is too old to connect back. Update it there, then try again.boxcannot reach this computer's gateway: box could not reach this computer on RustCode's port…, naming the addresses; or remote access here listens on this computer only (remote.bind).boxrefused or could not store the pairing (a locked credential store): whatrustcode machines connectprinted there. To retry only this direction, create a code here (rustcode machines code --link) and redeem it there (ssh box rustcode machines connect <link>).
Settings › Environments is the same pairing without a terminal: This Mac (or
This PC) first — the app's own RustCode server — then one row per saved
environment (a machine; the app says environment, after T3 Code), each with a live
status (Online, Connecting, Offline), its address and route, the RustCode version it runs
and its actions (Connect / Reconnect / Pair again, Rename, Settings, Remove). Add environment
opens one dialog with the ways in: Connect code, SSH and Address, each with its steps and, when
one fails, a plain message with Try again. ?machines=paired|connect|offline|revoked
in the mock shows the page without a second computer; connect starts with one
saved machine on an older RustCode reached over NetBird and the background
server not installed, so the add sheet, the version skew line and the install
are all visible at once.
| In the app | What it does |
|---|---|
| Add environment › SSH | A host field and Install or update RustCode there when needed (on by default), Who can reach whom (One way, the default: this computer reaches the host and not the other way round; Both ways: the host also sees and runs all threads on this computer), then the steps of Connect over SSH ticking off, each with its detail, and the failed step's sentence. A second direction that fails leaves the connection one way and says why under the steps. |
| Environments › ‹environment› › Access | The direction in the title (Both ways, This computer → it, It → this computer), then one row per direction: whether this computer reaches the environment (Allowed or Ended, removed with Forget) and whether the environment reaches this computer (Allowed with Revoke, or Not allowed with how to allow it). |
| Add environment › Connect code | Create connect code shows a code for another computer to pair with this one (Connect codes); I have a code takes a code or link created there, with an optional address for a short code that carries none. |
| Add environment › Address › Found on this network | The machines the Add sheet lists without an address typed, filled in when the page opens (Look again to repeat): Bonjour, the same browse as machines discover, for every machine whose record lists caps=desktops; and the saved machines whose handshake reported a NetBird, Tailscale or ZeroTier address, which no browse on the LAN can hear. One machine found both ways is one row, merged by its host id. Each row names the machine, its platform, the RustCode version it runs, the network its address is on (LAN, NetBird, Tailscale, VPN, Public) and the address. A machine not yet paired has Ask to connect; one already saved is Saved. When the local browse itself fails (a container or a network without multicast), the rows from the saved machines are still listed and the sheet says Could not look for machines on this network with the reason. |
| Add environment › Address › Enter an address | The host:port a machine is reached on when it is not announced (an overlay, or another subnet). The fallback, never the first step. |
| Waiting for ‹host› to accept on that machine… | The request is on that machine's screen; Cancel drops it. Once its owner accepts, the eight digits it shows are typed here, one box per digit. |
| Checking the code | The code is being checked with that machine, which answers within ten seconds. After half a minute the page stops waiting and says so — ‹host› did not answer — with Ask again and Cancel: a machine that accepted the code but could not store the token still lists this computer as a device, so it is removed there and the pairing started again. No stage of the flow is a spinner without a way out. |
| A machine row | Its name and platform, the state chip at the row's top right — Connected, Connecting, Reconnecting… (a machine that is away while the app retries it on its own), Offline, what a machine that answered and would not serve said — Server not running, Version mismatch, Not allowed, Busy, Refused — and Needs pairing — the route in use (else the one it answered on last) under the name as LAN · Unencrypted or NetBird/Tailscale/VPN · Encrypted, then when it was last reached and how many threads it has. Remove sits in a row of its own below. Under the route, the hint for one kind of route that answers while another does not (see Machines), and a version skew line when that machine runs another RustCode: RustCode 0.96.0 on that machine — update it there: Settings › About › Check for updates, or brew upgrade rustcode (the platform's own way, from install; a development build never shows the line), or update RustCode here when that machine is newer. |
| This computer · RustCode server | Whether the RustCode server is installed as a background service: Server: Installed · Running, or Not installed with Install server. Installed, that engine stays up when the app closes and starts when you log in; the threads this app starts still run in its own engine and end with it. What rustcode server status reports, including a running version that differs from the app's, is on the same line. |
| Remove | Forgets it here: token, routes and its threads' rows go. The machine keeps listing this computer until it is revoked there. |
| Pair again | On a machine whose pairing it ended (Needs pairing): asks it again, so a new code can be typed. |
The app keeps one connection per machine, retried with jittered backoff from a
second up to a minute (reset after half a minute connected), so a machine that
is asleep or away comes back on its own. Each attempt tries every known route —
the LAN, the .local name Bonjour gave it, every overlay address — starting
with the route the machine answered on last time, so a dropped connection comes
back the way it came. A change to this computer's own network (a VPN coming up
or going away, another Wi-Fi), a wake from sleep, the app coming to the front
or the network coming back, a route test that finds a route answering, and
list_machines with probe: true each end the wait at once instead of after
the backoff, and while it waits the row's chip says Reconnecting… next to
when the machine was last reached, rather than Offline, which reads as
something the user has to fix. A machine whose route answered but that would
not serve this computer is not Offline: the chip says what it said (its
RustCode server is not running behind the gateway, another protocol version,
this device not allowed), the row shows the route that answered, and the app
keeps trying, since most of these end on that machine's side. While connected,
the app listens for the machine: nothing at all for 75 seconds (two and a half
of the gateway's pings) is a dead link, and after a wake from sleep or a
network change it pings the machine and reconnects if no answer comes within
3 seconds on the LAN or 15 on an overlay. The first read of the machine list at app start is tried
again on a doubling wait too (state/machines.ts startMachines, 1 s to
30 s), so a shell that was still starting up does not leave every machine
without a connection until a page is opened. A pairing that was refused is not retried:
nothing changes until it is paired again, and the row says so.
The connection and the token live in the app's Rust shell
(apps/desktop/src-tauri/src/machines.rs); the page only shows what it is
told, and the thread count comes from the machine's own session/list over
ACP. Showing and driving those threads in the sidebar is the next step
(#3202).
-
Settings › Remote access › Revoke on a paired device does the same as
rustcode remote revokebelow. -
/remote off(orrustcode remote stop) ends access to every session for every device until/remoteis run again. -
rustcode remote revoke <device>forgets the device and, when the gateway is running, closes all of its connections and subscriptions in the same step; the device receives arevokederror and must be paired again. With the gateway stopped, the device is removed from the store directly. If a running gateway does not answer, nothing is revoked and the command says so. -
rustcode remote stopstops the gateway. Shared sessions keep running in their terminals and look for a gateway again for two minutes; after that they report that sharing stopped.
A paired device can browse the host's directories and start a new session in
one (list_directories, create_session). It sees and may use only folders
inside your home folder and the roots listed under [remote] in
config.toml (see configuration);
a folder elsewhere, one that does not exist, or a file is refused with
invalid_request and a message that says which, and nothing is started. The
device attaches to the new session as soon as it has registered. It is listed
for every device at once.
While the RustCode app is running with Remote access on, the session a device
starts is created in the app: the thread appears in the app's sidebar
within a second and the phone is attached to it. It runs on the harness the
device names — Claude Code, Codex, Pi, OpenCode or any other external harness,
or the built-in one — and on the model it names, exactly as a thread started in
the app. create_session takes optional harness and model; a device offers
that choice only when the gateway advertises thread_create_options, which it
does while the app shares at least one thread. An unknown harness, a harness
that is turned off, or a model the harness does not list is refused with
invalid_request naming the value, and nothing is started.
A thread started this way is the app's, like any other: it is shared while
Remote access is on, it ends when the app closes it, and it is open in the
app until then, so reopening it from a device or with rustcode --resume is
refused with open_elsewhere. The app runs it, so it follows the app's
harness settings, accounts and models; a device cannot name an account, an
effort or a worktree.
With no such share live — Remote access off, an app with no thread open, or a
terminal's engine, which never hosts threads — no terminal is involved either: the gateway starts a session owner, a
rustcode process without a terminal that runs in that directory, owns the
session and registers it like /remote does. It keeps running after the
device disconnects and across gateway restarts, and ends when it is killed or
the machine restarts. It always runs on the built-in harness, ignores
harness and model, and its session appears in the Mac app and the
terminal's session lists after its first exchange.
- Each owner writes to
remote/owners/<session id>.log: its start, its registration and the reason whenever it gives up. The request's receipt isremote/created/<hash>.json. - The device always gets an answer within 15 seconds of asking, well inside the app's own limit for starting a session (75 seconds; older apps 20), including any time spent waiting for another session to start.
- An owner that exits before registering is reported at once: the device gets
an
internalerror with receiptrejectedthat names the session, the reason, the owner's last output and its log. Nothing is left behind, so the device may ask again, with the same request ID or a new one. - An owner that is still starting after 15 seconds (a slow login shell, say)
is not stopped yet: the device gets
owner_unavailablewith receiptunknown, naming the session, and asks again with the same request ID. The gateway keeps waiting for that owner for up to 60 seconds; once it registers, the receipt holds the session and the retry is answered with it. An owner that has not registered by then is stopped and the receipt withdrawn, so the next attempt starts afresh. - A session has at most one owner process. A retry that finds the owner of an
earlier attempt still running (after a gateway restart, say) waits for that
one instead of starting another; if it still does not register, the answer
is
owner_unavailablewith receiptunknown, naming the receipt and the log.get_request_statuswith the request ID reportsappliedand the session once the owner has registered. - An owner never starts or replaces a gateway. One whose first registration is refused exits with the reason in its log. A registered owner that the gateway drops (the machine slept, for example) registers again by itself; after five refusals in a row it exits.
rustcode remote stopstops the gateway only. Owners keep running and register with the next gateway.rustcode remote statuslists owners that are running without being shared underidle owners.
A device also sees the sessions saved on the host that are not shared right
now, newest first and searchable (list_saved_sessions), and can reopen one
(resume_session). Reopening works exactly like starting a new session: the
gateway starts a session owner on the saved session, in the workspace
recorded with it, and the device attaches once it has registered. The owner
picks up the transcript, the session's settings and its task worktree. The
same receipt, retry, 20-second start limit and owner log rules apply.
Only sessions in the host's session store can be reopened, by their full ID.
A session that a terminal, the RustCode app or another owner has open without
sharing it is refused instead of being opened twice (the app shows where it is
open: turn on Remote access for it in the Mac app, or run /remote in the
terminal), and so is one with no recorded
workspace or whose workspace directory no longer exists: reopen those in a
terminal with rustcode --resume. The gateway advertises this with the
saved_sessions capability; an older gateway lists only shared sessions.
From a shared session, More ▸ Review Changes on the phone shows the session's
uncommitted changes: how many files and lines changed, when they were read,
the branch and commit, each changed file, and each file's diff. Tap lines to
select them, then Ask About … Lines to quote them, with their numbers,
in the session's message field. Nothing is sent until you send it, and the
draft stays through a disconnect. Reading the changes runs no Git command
that writes. Large diffs are cut and say so. The gateway advertises this with
the session_changes capability.
To continue a device-started session in a terminal, run
/adopt <session-id> there: it asks the detached owner to exit (gracefully,
saving history first), waits for its lease to release, then opens the session
exactly like a resume. The terminal shares it again by itself; devices
re-attach with a fresh epoch. /adopt needs the full session id, never runs
from a device, and refuses when the owner cannot be identified safely.
The gateway is its own process, and whoever starts it is only its launcher.
With the RustCode server installed (docs/server.md), the server starts
the gateway whenever remote access is on and starts it again if it stops, so
quitting the app leaves port 17879 answering and this machine's server threads
reachable from the other machines. Only one gateway runs at a time: the app
uses the running one instead of binding its own, and the server adopts the
app's, so the port never has two owners (rustcode server status › Gateway
says which one runs and who started it). What the server starts it with —
bind, port, advertised address, interface preference — is the settings the
last gateway recorded in remote/settings.json, which only the gateway that
won the owner lock writes, or [remote] in config.toml on a machine that has
not run one since that file existed.
Two honest limits: the threads this app starts run in the app's own engine and still end with it, because they are not the server's threads; and a gateway the app or a terminal started keeps running only as long as that process tree does, until the server restarts it.
On macOS an installed rustcode runs the gateway as a login agent when no
server is installed. The agent keeps the search path it was installed with, so
asking for the gateway from a terminal with another environment no longer
rewrites the agent and restarts the gateway under its devices. Installing the
RustCode server removes the agent: the server supervises the gateway instead,
and two supervisors would race for the same port. Only the process launchd was
running for the agent is replaced — its pid is read before the job is booted
out — so a plist left behind after the job was unloaded never stops a gateway
the app or a terminal started, which the server adopts as usual. rustcode remote stop
removes the agent; the next start installs it with the current environment.
- Same Wi-Fi or LAN: nothing to choose.
autopicks this machine's private IPv4 address, the QR code carries it, and the listener follows DHCP changes by itself (see Automatic address selection). - Do not type the
.localname.mac.localresolves over mDNS to every address the machine has, IPv6 and VM bridges or VPNs included, and a phone may try one where the gateway does not listen (-1004 could not connectin the app). Use the IPv4 address from the QR code orremote status. - IPv6:
autoprefers IPv4 and uses a routable IPv6 address only when the machine has no LAN IPv4 address. Link-localfe80::addresses are never used: they need a scope the phone cannot know.--bind <ipv6>chooses one explicitly; pairing details then show it as[address]:port. - Away from home: join the same NetBird, Tailscale or ZeroTier network on
both machines. With an
autobind the gateway already listens on the overlay address (see Automatic address selection), and a paired client learns it fromroutesand tries it after the LAN (see Machines). Nothing has to be typed. If the overlay still does not answer,rustcode machines listsays what to check on that machine: NetBird's Block inbound connections, a NetBird access policy that allows TCP17879between the two machines, Tailscale's ACLs or grants for that port, or ZeroTier's flow rules. - A fixed setup:
rustcode remote serve --bind <netbird ip> --advertise <host>.netbird.cloud(or the same two values as[remote] bindandadvertise) pins the mesh address and a name that never changes. On Linux--prefer-interface netbirdmakes the mesh address the advertised one while LAN and overlay listeners stay open. - Bonjour announces the service name, port, protocol version,
gateway identifier and optional handshakes (
caps=connect_requests,desktops), but no address on purpose: the phone resolves the announcement itself on the network it is on, so the record cannot go stale after a DHCP change and never points at an interface the phone cannot reach. A paired phone that finds its gateway identifier at a new address reconnects there with its token.
The gateway announces itself with its own mDNS responder (the mdns-sd
crate) on macOS, Linux and Windows alike; it needs neither dns-sd nor Avahi
installed. The record:
| Field | Value |
|---|---|
| Service type | _rustcode._tcp in local. |
| Instance name | the machine's name (the computer name on macOS and Windows, the host name on Linux), else RustCode |
| Host | rustcode-<gateway id prefix>.local., RustCode's own name, so it never competes with the name the system's responder already answers for |
| Port | the gateway's port |
| TXT | protocol=1, gateway=<gateway id>, caps=connect_requests,desktops |
Each network interface answers with its own addresses, and the record follows
address changes. A gateway that listens on loopback only is not announced:
no phone could reach it. Stopping the gateway (remote stop, Ctrl-C,
SIGTERM, or the console closing on Windows) withdraws the record with a
goodbye, so phones drop the machine from their nearby list at once; a gateway
that is killed outright disappears when the record's lifetime runs out.
To see the announcement from another machine: dns-sd -B _rustcode._tcp on
macOS, avahi-browse -r _rustcode._tcp on Linux. Windows has no built-in
browser for it; the phone app's nearby list is the check there. Windows
Firewall must allow rustcode on private networks, for UDP 5353 (mDNS) and
the gateway's TCP port; Windows asks the first time the gateway listens.
- Transport: no TLS (the full threat model is in remote-security.md). On a plain LAN anyone on the network can read and alter the traffic. Use it only on a network you trust; NetBird encrypts the traffic end to end and is the recommended path off the home LAN. Never forward the port to the internet. The app shows an unencrypted-LAN banner for a plain address.
- Pairing: a 2-minute, single-use challenge with five wrong codes; ten
failures in five minutes lock pairing for the host. Only the local user can
ask for a challenge (control socket
0600, or on Windows the endpoint's token; see Local sockets). - Access is per host. A paired device can read and drive every session this machine shares, now and later, for 30 days or until revoked. There are no per-session grants and no per-session opt-out. Per-session grants have been considered; they are not planned for now.
- Ending access:
rustcode remote revoke <device>closes that device's connections in the same step and forgets its token;/remote offorrustcode remote stopends access for every device (see Revoking and stopping). - Browsers cannot reach the gateway: an upgrade request with an
Originheader is refused, except the exactrustcode-mobile://appa native phone app sends, which no browser can produce.
Settings. Every gateway writes what it listens on and announces to
remote/settings.json as it starts: bind (auto or an address), port,
advertise, prefer_interface and who started it. The RustCode server reads
it to start the gateway at login with no app or terminal around. The address
an auto bind resolved to is never stored; it belongs to the machine of the
moment.
Local sockets. The gateway has two Unix sockets in remote/ under the
configuration directory (0700), each 0600: control.sock for the
rustcode remote … commands and owner.sock for terminals that share a
session. Only the user who runs the gateway can connect; the owner socket also
checks the peer's user ID. If the configuration path is too long for a socket
address, both sockets go to /tmp/rustcode-<uid>/<hash>/ instead, created
0700 and verified to belong to you.
On Windows, where Tokio has no Unix sockets, each endpoint listens on
127.0.0.1 at a port the system picks, and remote/control.endpoint and
remote/owner.endpoint hold that port and a random 256-bit token. Those files
are in your profile, whose permissions admit only you (and the system and
administrators), like the device store. A client sends the token before
anything else; a connection that sends anything else, or nothing within five
seconds, is closed unanswered. The token is new every time the gateway
starts.
Liveness. A terminal's connection to the owner socket is what keeps its
session listed. The terminal sends a heartbeat every five seconds; after 15
seconds of silence the session is listed as unresponsive, after 45 it is
removed. When the terminal exits, stops sharing or switches session, the
session is removed at once.
Receipts. Every mutation a device sends carries a request_id. The
terminal that owns the session keeps, per device and request ID, what it did
with it. Sending the same request again returns the original outcome and
applies nothing; the same ID with different content is refused. Because the
receipts live in the terminal, they survive a gateway restart. If the terminal
is gone, the answer is unknown: the device shows that it does not know and
never resends on its own.
Ordering and reconnect. Each terminal numbers its updates. The gateway keeps the last snapshot and up to 4 MiB of the updates after it per session. A device that reconnects with its last sequence number gets exactly the updates it missed when they are all still there, and a fresh snapshot otherwise. A cursor from before a gateway restart always gets a snapshot.
Slow devices. Each device has its own bounded queue. A device that does not read fast enough is held back, told to resynchronise and given current state when it catches up; one that stops reading altogether is closed. Neither delays the terminal or another device.
| Bound | Value |
|---|---|
| Handshake, from TCP accept to first frame | 10 s |
| Unauthenticated sockets | 16 in total, 4 per peer address; extra sockets are dropped on accept |
| Handshake frame | 8 KiB |
| Any frame from a device | 256 KiB for a request; 1 MiB closes the connection |
| Any frame to a device | 256 KiB |
| Authenticated connections | 32 |
| Paired devices | 32 |
| Shared sessions | 32 |
| Frames queued for one device | 64; responses that overflow close that device with slow_consumer |
| Requests one connection may have waiting on terminals | 16; more are refused with rate_limited |
| Wait for a terminal's answer | 30 s, then owner_unavailable |
Answer to create_session or resume_session |
within 15 s of the request; an owner still starting then gets owner_unavailable |
Answer to a create_session the desktop app starts |
30 s; a harness that needs longer gets owner_unavailable with receipt unknown, and get_request_status reports received until the thread exists |
| Wait for a new session's owner to register | 60 s, then the owner is stopped and its receipt withdrawn |
| Interval at which the listen address is resolved again | 5 s |
| Replay kept per session | 4 MiB of events |
| Mutation receipts per registration | 1024; more mutations are refused with receipt_capacity |
| Silence before a device connection is closed | 90 s; any frame or WebSocket ping from the device counts (the gateway pings every 30 s) |
An upgrade request with an Origin header is refused with 403, unless it is
exactly rustcode-mobile://app: browsers send an http(s) origin and a page
cannot set headers on a WebSocket, so a web page cannot reach the gateway. The
native app sends that value because React Native always adds an Origin
header; see the connection step.
The gateway, pairing, connection requests, Bonjour and Settings › Remote access work as on the other systems. What differs:
- No gateway login service of its own. Remote access started from the app,
/remoteorrustcode remote startruns the gateway as a detached background process (no console window, its own process group); macOS also installs a LaunchAgent for it when no RustCode server is installed. With the server installed, the server's own logon task hosts the gateway, so the machine stays reachable the same way as on the other systems. Without a server, the gateway stops when you sign out or restart, and the next RustCode start brings it back while Remote access is on. - Local endpoints are loopback ports with a token instead of Unix sockets (see How it holds together).
/adoptof a thread a device started is refused: Windows cannot ask its session owner to exit gracefully. End it on the device, then resume it from/history.- Firewall: allow
rustcodeon private networks when Windows asks, or a phone can neither find nor reach the gateway.
- No TLS; see the warning at the top.
- The terminal owns execution. Closing it ends the session's availability. A session started or reopened from a device is owned by its session owner process instead.
- A device starts sessions only in folders inside the home folder or a
configured
[remote] rootsentry, and cannot create folders. - A session the gateway started as a session owner shows up in the Mac app and the terminal's session lists only after its first exchange; one the app started is in the app's sidebar at once.
- Opening a thread the app started for a device from the app itself, while the app still runs it, answers "session is already active": a live session cannot be opened twice. Close the thread in the app and open it again.
- No push notifications.
rustcode machinesreaches a machine over its WebSocket only: no SSH route (SSH only sets a machine up and pairs it, Connect over SSH), no relay, and a route list is not reordered or learned yet (steps 1.8 and 1.11 of the machine network plan). Connect over SSH pairs one way unless Both ways is chosen (Mutual access); a connect code or an address request still pairs one way, and agents cannot start a pairing yet (noconnect_machinetool).- Access is per host: a paired device sees every session open in a terminal. There are no per-session grants and no per-session opt-out.
- Reachability from a real iPhone (local-network permission, NetBird routing) is verified with the app, not by this repository's tests.
After authentication, a device with the host-controlled acp grant can send
{"type":"acp_open"}. The gateway opens ACP on the engine running in its
configuration directory, taking the first that accepts a connection: an engine
the app started (run/engine.json, which holds the threads the app is showing)
or a shared server (run/server.json). A description left behind by an engine
that has since stopped is skipped. It answers {"type":"acp_opened"}. Subsequent
WebSocket text frames each contain one ACP
JSON-RPC message, in either direction. These additive frames keep protocol
version 1. The grant defaults to false, including for existing device records.
Self-reported device kind never grants access. Accepting a desktop's ask-to-connect
request grants ACP after it enters the request-bound code; the approval says
"Can see and run all threads on this machine". Pairing with a standalone short
code never grants ACP. The owner can run rustcode remote devices allow <id>
or rustcode remote devices deny <id>; deny immediately disconnects the device.
Devices without the grant receive a gateway
error with code unauthorized_access when opening ACP. When no engine is
running, or none of the described ones answers, the gateway started by
rustcode remote serve starts the server and relays to it once it listens
(within about 7 seconds; starts back off, see docs/server.md). When that
fails too, the connection is closed with internal, which a client reports as
server_not_running, and the gateway writes why to its log.
Client requests and notifications are restricted to these exact methods:
initialize_rustcode/session/status,_rustcode/session/snooze,_rustcode/session/repair_rustcode/harnesses,_rustcode/harnesses/default,_rustcode/harnesses/set_rustcode/settings/get,_rustcode/settings/set_rustcode/skills/add,_rustcode/skills/get_rustcode/environment/sync: names, versions and last-seen times of the fleet (Environment names and the fleet)_rustcode/threads/search,_rustcode/data/activity,_rustcode/project/read,_rustcode/project/cloneand_rustcode/worktrees/state: what an agent on the paired computer reads and does here (docs/orchestrator.md› Machines)
The only allowed prefixes are session/, _rustcode/pulls and
_rustcode/capabilities (which includes adding an MCP server, that is, running
a command on this machine: consistent with "can see and run all threads").
Everything else is denied, including provider credentials and login, pairing
and remote management, _rustcode/server/*, data-store SQL and usage, project
creation and worktree handoff. Denied requests receive a JSON-RPC
error with the same id, numeric code -32601 and
data: {"code":"unauthorized_access"}. Denied notifications are dropped.
JSON-RPC responses pass through only for an outstanding server request ID,
once per request. Unknown or repeated answers are dropped. At most 1,024 server
requests may be outstanding; exceeding this closes with slow_consumer.
Batch messages are refused.
session/load resume metadata (_meta.rustcode.resume: {cursor}) passes through
without modification. The gateway translates WebSocket messages to the local
server's newline-delimited JSON; JSON is compacted without changing JSON string contents or resume metadata. Local-server frames retain the gateway's 1 MiB
frame limit. ACP has its own outbound queue capped at 1,024 frames and 16 MiB, including
the frame being written. Overflow
closes the device with slow_consumer; it never blocks other gateway clients.
Revocation and gateway shutdown still close ACP connections, and closing either
endpoint closes the other.