Repository navigation
feat(machines): add a PC through the relay, without SSH - #2829
Conversation
This PC's server pairs with another PC's Ovis as a relay device (same link, code, Noise NK and device key as a phone) and serves it to the remote-PC code through a loopback forward, so its roster, status, API routes and terminals work unchanged. The relay client core moves from src/lib/relay to shared/ so the server can use it; the browser keeps thin wrappers.
|
Note Currently processing new changes in this PR. This may take a few minutes, please wait...
|
- a pairing or sign-in that finishes after the PC was removed writes no key - a cancelled request waiting for a stream place is never sent - a pairing link needs HTTPS or localhost, like an SSH password - bound what a socket sends before the other PC accepts its stream - close a socket whose output cannot be delivered (4008) - an add whose registration cannot be saved leaves no PC, tunnel or key
|
Adversarial review (Codex, gpt-6.1-terra, xhigh) of cba4f0b and how each finding was handled in 0943f83. CodeRabbit hit its review limit on this PR, so this and the omo review stand in for it.
Validation at 0943f83: |
# Conflicts: # src/lib/relay/channel.ts
- the loopback forward has no idle cut of its own (Bun's default ends a request after 10 s), and its mux requests no 60 s deadline: the machine proxy's own deadlines bound each request - removing a relay PC deletes its key before its registration is saved - an add that cannot be saved says to revoke the device the other PC already lists
|
omo review of a0de610 (BLOCK) and how each finding was handled in 6725d41.
Validation at 6725d41: |
A release from before relay PCs refuses a machines.json entry it does not know and would not start after a rollback, so relay PCs are saved in relay-pcs.json. Only a missing or damaged relay key stops retries; another read error is retried.
|
omo re-review of 6725d41 (BLOCK) and how it was handled in b2eb707.
Validation at b2eb707: |
|
omo review at b2eb707: CLEAR (receipt at this head). Remaining notes from it, not changed in this PR (the one fix round is used):
The PR body is updated: relay PCs are stored in |
Two buttons named Add PC in one dialog are ambiguous for a screen reader and for scripts/ui-regression.ts.
|
Merge note: owner's own feature (relay link between PCs), owner-delegated. omo CLEAR at 69006da after three fix rounds (each reproduced HIGH fixed with tests), Codex HIGHs fixed, required checks green. CodeRabbit rate-limited; follow-up #2833. Overlapping open PRs (#2830, #2840, #2731) will merge main after this. |
…ay (#2832) ## What Regional relay endpoints. From Korea, Cloudflare serves `relay.ovis.sh` (and `app.ovis.sh`) from Los Angeles: a TCP connect from a Korean PC takes ~133 ms (`cdn-cgi/trace` says `colo=LAX`, `loc=KR`), and opening Ovis through the relay costs several such round trips on both legs. `relay-ap.ovis.sh` runs the same relay (`relay/bun.ts` behind Caddy) on a VPS in Seoul, ~5 ms away. - **PC (`server/relay/service.ts`).** With no `OVIS_RELAY_URL`, starting the relay measures `/health` on `relay.ovis.sh` and each regional relay (`REGIONAL_RELAYS` in `shared/relay-regions.ts`). A regional relay that answers in at most half the global relay's round trip, with both answering, is saved in `relay/region.json` and kept. The PC then keeps its link to `relay.ovis.sh` and opens a second `RelayLink` (same identity and proof) to the regional relay, and new offers name it. `OVIS_RELAY_URL` set: one relay, as before. The global link starts at once, as before; the measurement never delays it. - **Phone and PC-to-PC (`shared/relay-channel.ts` `openRelayChannelWithFallback`, used by `src/lib/relay/transport.ts` and `server/relay-pc.ts`).** When the saved relay is one of Ovis's regional relays and the attempt fails with `RelayUnreachableError` or `RelayPcOfflineError` (so no pairing code was spent), the transport tries `relay.ovis.sh` at once and keeps using whichever got through. Any other relay never falls back, so a self-hosted relay is never traded for Ovis's. - Wire formats are unchanged: the offer already carries the relay, and a phone keeps the relay it saved, so phones paired before keep using `relay.ovis.sh`. Telling those phones about the regional relay (inside the encrypted channel, behind a mux feature) is a follow-up. ## Measured Over the real network from a PC in Korea, the same open path (phone TLS socket, PC data socket + proof, Noise hello/ready, mux HELLO/WELCOME, AUTH/AUTH_OK, three requests) played by a script with placeholder frames in the real order and number; 14 opens each, ABBA, medians: | relay | AUTH_OK | first screen (3 requests) | range | |---|---|---|---| | relay.ovis.sh (served from LAX) | 4605 ms | 7201 ms | 4999-10405 ms | | relay-ap (Seoul VPS, `relay/bun.ts` behind Caddy) | 87 ms | 123 ms | 112-138 ms | The controlled version below isolates the round-trip effect: A container running exactly the VPS setup (Caddy TLS + `relay/bun.ts`), with `tc netem` adding the relay's round trip, and a script that plays the phone and the PC with the open path's frames in the real order and number (phone TLS socket, PC data socket + proof, Noise hello/ready, mux HELLO/WELCOME, AUTH/AUTH_OK, three requests). 14 opens per delay, ABBA order, medians: | relay round trip | AUTH_OK | first screen (3 requests) | |---|---|---| | 133 ms (relay.ovis.sh from Korea today) | 1622 ms | 2427 ms | | 45 ms (Tokyo) | 572 ms | 850 ms | | 14 ms (Seoul VPS) | 195 ms | 283 ms | ## Tests - `advertises a regional relay that answers much faster, stays on the global one too, and keeps the choice` (two local Bun relays; a phone pairs and fetches through each; a restart reuses the choice without measuring) - fails on main. - `stays on the global relay alone when a regional one is not much faster or the global one does not answer` - fails on main (no measurement). - `falls back to the global relay when a regional one cannot be reached, and reconnects through it` - fails on main. - `never falls back from a relay that is not one of Ovis's regional relays` - guards the self-hosted case (passes on main by design). ## Notes for review - Session limits are per link (8 signed-in sessions, 4 per device, on each relay). - An offer fetched before the measurement finishes (a second or so after the relay is turned on) names the global relay; a phone paired from it simply uses the global relay. - Codex adversarial review on the first commit found three MEDIUMs, all reproduced and fixed in the second commit with a test each (fail before, pass after): a late measurement saved after stop, offers naming a regional relay whose link was down (status now follows the global link), and `OVIS_RELAY_URL` naming a regional relay leaving its phones a fallback the PC was not on. - The privacy policies (`ovis-site/privacy`, `ovis-site/ko/privacy`) and both store listings name `relay-ap.ovis.sh` beside `relay.ovis.sh` (no operator or host stated). ## Validation At the head after merging origin/main (conflict in `src/lib/relay/channel.ts`: #2829 moved the channel into `shared/relay-channel.ts`, so the `carrier` option moved there): - `bun run check fast`: workflow syntax, generated types/themes, typecheck, build, unit tests. - `bun test server/relay-pc.test.ts server/relay-pc.contract.test.ts server/relay/service.contract.test.ts src/lib/relay/ shared/` (604 pass); the service contract tests are not in the fast unit set. - Every new test was run against the code before its fix and failed there. - Live: `https://relay-ap.ovis.sh/health` answers 200 (Let's Encrypt); the open-path script reaches the first screen through `wss://relay-ap.ovis.sh` in 127-144 ms. - Skipped: `check full` (Integration and browser run in CI), and a real phone through the regional relay. - Seen once in a full parallel run, not reproduced alone (3/3 pass) and also seen on main: `server/relay/session-ws.contract.test.ts` "drops a key not yet typed when the phone closes the socket" timing out under load; this PR does not touch it.
) Fixes #2810 ## What #2829 added **Settings → Remote PCs → Add a PC through the relay**, which pairs this PC with another one by its pasted pairing link: no SSH, no open port, no Tailscale, end-to-end encrypted, B's key pinned, revocation on either side ending it, offline/reconnect through the remote-PC backoff, and a phone paired with A seeing B through A. The piece of #2810 still missing was the typed code: a desktop has no camera for the QR code, and copying a link from one PC to another needs some channel between them. The same row now takes either the link or the ten-character code the other PC shows under **Settings → Phone & devices → Show a code to type** (#2883): 1. Type the code, **Add PC**. 2. This PC's server finds the other PC through the relay, exactly as the hosted app's code screen does, and answers only that PC's ID and name. 3. The row asks **Does {name}'s screen show this ID?** with the ID in eight groups, the same wording and check as the phone's code screen. **It matches** adds the PC; **Cancel** goes back. A pasted link still adds the PC in one step, as before. ## How - `src/lib/relay/pairCode.ts` (the device side of the typed-code exchange: one CPace exchange per call, offer key checked against its id, offer must carry a pairing code) moves to `shared/relay-code-offer.ts`, so the server can run it too (server code may not import `src/`). `pairCode.ts` re-exports it, so the hosted app is unchanged. The only code changes in the moved file: the forged-offer check calls `deriveServerId(importPublicKey(...))` directly instead of `shellState.forgedOffer`, the socket type is `RelaySocket`, and the answer frame is sent as an `ArrayBuffer`. - New `POST /api/machines/relay/code { code }` → 200 `{ ticket, server_id, name }`, or `typed_code_<failure>` (`not-a-code` 400, `no-pc` 404, `wrong` 400, `expired` 410, `busy` 409, `unreachable` 502, `forged` 502). Same guards as every PC change (same origin, `X-Herdr-Machine: 1`, HTTPS or localhost, watch devices refused). - The offer it found holds B's one-time six-digit code, so **it never goes back to the browser** (src/AGENTS.md: secret input stays in the DOM). `MachineManager` keeps each found offer in memory under a random 32-byte ticket that only the asking window gets (at most 8, 10 minutes each, B's own pairing-code lifetime); `POST /api/machines/relay { found: <ticket> }` takes it once. Another client that knows B's public ID gets nothing. The find also stops when its request is aborted. - From there `addRelay` runs unchanged: the pairing, key storage and reconnects are #2829's. - `codeFailureText` (the code screen's failure sentences) moves out of `RelayShell.tsx` into `src/components/codeFailureText.ts`, so both screens word failures the same. Those keys move from the relay dictionary part to the base dictionaries, since two features now use them. ## Wire changes (additive) - New `POST /api/machines/relay/code`. - `POST /api/machines/relay` accepts `found` instead of `link`. - Nothing changes between PCs or on the relay: A is one more typed-code guest, as a phone is. ## UI The design follows GitHub's device sign-in (one screen shows a short code, the other asks for it in a single field), as #2883 does. One input takes a link or a code, so the row stays one control. The ID comparison reuses the phone's wording and `KeyId`. Screenshots at 390 and 1280, light and dark, from the demo build with the two API answers stubbed in the page: entry row, ID comparison, and a wrong-code message. There is no sideways scroll at either width, and **It matches** is 44 px tall on the phone. ## Validation - Failing-first: `server/relay-pc.contract.test.ts` "finds a PC by the code it shows, keeps its offer while its ID is compared, and adds it once that ID is confirmed" uses two isolated servers (mkdtemp state, port 0) and a local Bun relay. B mints a code, and A checks a non-code (400), a code no PC holds (404), the real code (a ticket, the ID and the name, with no pairing code in the answer), the spent code (404), `found` set to B's public ID instead of the ticket (400), the ticket (201, connected), and the same ticket again (400). On origin/main's server it fails at its first request (404). With the change, all 4 tests in the file pass. - `src/components/AddRelayPc.test.tsx`: a code goes to `/relay/code` and leaves the field; the ID shows; **It matches** posts `{ found }`; a `typed_code_wrong` answer shows the code screen's sentence; a link still posts `{ link }` in one step. - `bun test src/lib/i18n.test.ts src/components/conventions.test.ts src/lib/relay src/components/RelayShell.test.tsx shared/relay-pair-code.test.ts server/conventions.test.ts`: pass. - `bun run typecheck`: pass. - `bun run check fast` at f8dc4a8 (origin/main merged in), on old-desktop through the heavy runner: workflow syntax, generated types/themes, typecheck, build and unit tests ok (11917 pass, 0 fail, 46 skip). - Not run: `bun run check full`, browser/e2e, native Windows (CI runs them). The changelog fragment of #2829 (`relay-remote-pcs.md`, unreleased) is extended rather than adding another. ## Not in this PR - Linking PCs that are signed in to the same Ovis account without any code. Account sign-in is on main, but admitting a PC to another PC by the account alone is a separate trust decision. - A pairing link opened on a PC with the desktop app opening in the app (the desktop side of #2599).
…2955) Fixes #2497 ## What With `OVIS_RUNTIME=herdr-core`, every other machine the core reaches (`hc machine add vps`, or added in the Herdr app) is now a PC of its own in the sidebar, reached through this PC's core. Nothing of Ovis is installed on that machine. Before, those machines did not show at all (since #2795 their terminals were only kept out of this PC). A PC of the core shows: - its workspaces and tabs, as its own `herdr-app` document lays them out (`doc.get { on }`), or as Ovis's labels and `ovis-layout` document there; - its live terminals, typing, screen reads, status and alerts; - new workspaces, tabs and splits, started there (`terminal.run { on }`). ## How The server already shows a remote PC through an endpoint `{ url, token }` that serves herdr's API (an SSH bridge, or the relay forward since #2829). A machine of the core gets the same thing, run locally: - `server/herdr-core/machine-scope.ts`: `MachineScopedConnection`, an `HcConnection` that shows the core as that machine sees itself. Only `lab/…` terminals are listed, without the prefix (`parent` too). Every address goes back with the prefix. `terminal.run`, `doc.get` and `doc.set` get `on: lab`. The event stream keeps only that machine's events, including its `doc` events (`machine: "lab"`). The adapter, the attach and the `herdr.seen` marker therefore run unchanged. - `server/herdr-core/machine-entry.ts`: a server per machine (`createServer({ bridgeOnly: true })`) on loopback with a random token, over that connection. `OVIS_HC_MACHINE` selects the scoped connection in `runtime.ts`. - `server/herdr-core/machine-bridge.ts`: `listHcMachines` (`machine.list`, temporary machines left out) and `startMachineBridge`, which starts that server as a child. The child runs with an empty home folder of its own and only the program and core-finding variables. This PC's agent transcripts and processes are not that machine's, so none can be taken for one of its panes. For the same reason, `pane.process_info` gives no pids for another machine, and `worktree.*` is refused there, because git would run here. The child ends when this server's stdin pipe to it closes. - `server/machines.ts`: a new PC kind, `hc`, read from `machine.list` every 5 s. Its id is `hc-<machine id>`, which is stable across renames. A rename restarts its server under the new name, because addresses change. A machine that is not connected shows hc's reason. hc PCs are never saved to `machines.json`, and rename, remove and setup are refused ("add, rename or remove it with `hc machine` or in the Herdr app"). In the sidebar, the PC's manage panel says the same instead of offering those buttons. Answers to the issue's open questions: 1. SSH and relay PCs stay listed beside the core's machines. 2. The PC id is the hc machine's permanent id. Safety (from the review round, each reproduced first with three isolated cores, one in its own mount namespace): - hc names a machine by a name another machine can take (`hc machine rename`). Every scoped call goes to hc under the machine's permanent id (`<id>/t3`, `on: <id>`), which hc takes wherever it takes the name, so no call can reach another machine. The current name, looked up by id first, is used only to filter what hc lists and reports by name. Before this, input to the old `lab` landed on `vps` after `lab→lab2`, `vps→lab`; after it, the call is refused and nothing reaches `vps`. - The machine's server (`localFiles: false`) refuses the routes that read or write this disk for its panes: `/api/fs/*`, `pane/files`, `pane/image`, `workspace/diff/*`, `workspace/directories`, `workspace/pr*` (gh and git run there). Before this, it served a file only this PC has and could not see a file only that machine has. A new workspace's folder goes to that machine unchecked: a folder that exists only there now works. - Its adapter socket sits in a short temp folder, so a long state folder cannot push it past the unix socket limit. A scoped event line past 4 MiB is passed whole to the adapter's reader, which refuses it. Known limits (in docs/guide.md): - Chat history of agents on that machine is not read: their transcripts are there. - That machine's files are not reachable yet (refused, not guessed). The client still offers those entry points and shows the refusal. - Pins and "seen" are not shared with the Herdr app on that machine. - Each machine costs one more Bun process on this PC. ## Validation - `bun run check fast`: pass on the merged head (11959 pass, 0 fail; typecheck, build, generated types fresh). - `bun test server/herdr-core/ server/hc-machines.test.ts`: pass. The new scoped-adapter tests and the manager tests go red when their owner is broken: `on` dropped, another machine's terminals kept, pids kept, hc PCs saved, no restart on rename, no id check, disk routes served. - `OVIS_TEST_HC_BIN=<hc 0.1.14> bun test ./server/herdr-core/live.test.ts` and the same with hc 0.1.17: pass. The new live test starts two isolated cores, adds the second with `hc machine add --via`, then starts, types into and reads a terminal there through the scoped adapter. - Manual: a real server on an isolated core holding machine `lab` (another isolated core). `lab` appears as a connected PC with its own workspaces. Its live terminal attaches with history, and typing in the browser reached `lab`'s shell (`hc read` there showed it). Workspace create, input and read through `/api/machines/hc-…/` all work. The local PC shows only its own terminal. Stopping the server ended the per-machine child. - Screenshots of the manage panel, dark and light at 1280 and 390, are in the comment below.
…dd leaves no entry (#2894) Fixes #2833 ## Problem Adding a PC through the relay saves two files: `persist` wrote `relay-pcs.json` first, then `machines.json`. When the second write failed (a full disk, an unwritable state directory), `addRelay` rolled the PC back in memory and deleted its relay key, but the entry already written to `relay-pcs.json` stayed. After a restart the PC came back without its key and showed "This PC's relay key could not be read." ## Fix - `persist`'s `relay-pcs.json` half moves into `persistRelayPcs()`. - `persist(relayLast)`: the save that adds a relay PC (`addRelay`) writes `relay-pcs.json`, the new PC's own registration, after `machines.json` and the herdr profile state. A save that fails before that write never puts the PC on disk, so nothing needs taking back. `writePrivateState` leaves the destination as it was when the write itself fails. - Every other save keeps the old order, with `machines.json` last, so a PC moved by setup is saved there only when nothing else of its save can fail (#2075). A first version rewrote `relay-pcs.json` in the rollback instead. Review showed that the second write fails the same way on a full disk, and is skipped when deleting the key throws (see the review comment), so this version avoids the write rather than undoing it. No changelog fragment: adding a PC through the relay (#2829) has not been released yet, and its `relay-remote-pcs.md` fragment covers it. ## Validation - Failing-first test: `server/relay-pc.contract.test.ts` "leaves no relay PC behind when its registration cannot be saved" turns `machines.json` into a directory, adds a PC through a local relay, and expects a 400, no key, no `relay-pcs.json` at all (none existed before), and no relay PC after a restart. Against origin/main's `server/machines.ts` it fails (`relay-pcs.json` exists and lists the PC). With the fix, all 4 tests in the file pass. - `bun run check fast` at f236f4b (730e59c merged with origin/main, which includes #2886): workflow syntax, generated types/themes, typecheck, build and unit tests ok (11611 pass, 0 fail, 46 skip). Before #2886 merged, the only failure was the main-wide i18n line cap. - Not run: `bun run check full`, browser/e2e, native Windows (CI covers them). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

What
Settings → Remote PCs → Add a PC through the relay adds another PC with no SSH and no open port. Paste the pairing link from that PC's Settings → Phone & devices → Copy link; its workspaces join the sidebar like an SSH PC's.
Related: #2497 (other machines without SSH bridges; that issue is about herdr-core machines, this one works on either runtime).
How
Ovis on <hostname>, and can revoke it.server/relay-pc.tsopens that connection and serves it on127.0.0.1:<random port>behind a random bearer token. That is the same{ url, token }endpoint an SSH local forward gives, soMachineManager's refresh, status socket, source proof, the machine API proxy and terminal sockets run unchanged. Each request or WebSocket is one mux stream; a request waits its turn when all 32 stream places are taken.MachineManager:kind: "relay"PCs are stored inrelay-pcs.jsonwith their relay id (apart frommachines.json, so an older release still starts after a rollback);reconnect()opens the relay tunnel instead of SSH; a refusal for good (revoked, identity changed, unsupported version) sets the PC toerrorand stops retrying. Bridge updates and setup jobs are refused for relay PCs (they run their own Ovis).<stateDir>/relay-pcs/<pc id>.json(0600) only after the other PC admitted it; Remove PC deletes it.openRelayChannel, device-key signing/pinning) moved fromsrc/lib/relay/toshared/relay-channel.tsandshared/relay-device.ts, since server code may not importsrc/(remote bundle boundary).src/lib/relay/channel.tsanddevice.tskeep thin wrappers that default to the IndexedDB store, so the hosted app is unchanged.Wire changes (additive)
POST /api/machines/relay { link, name? }→ 201{ machine }, behind the usual PC-change guards (same origin,X-Herdr-Machine: 1, watch devices refused).Machine.kindgains"relay"; new optionalMachine.relay { server_id, relay }.UI
Settings → Remote PCs gets one row (paste field + Add PC). The pairing link holds a one-time code, so it stays in the DOM input only. The sidebar's manage menu shows for relay PCs (rename, disconnect, remove) without the SSH-only bridge buttons; removing one reminds you to revoke it on the other PC too. Follows the existing Add PC / Phone & devices pattern; the paste-a-link flow is the one the phone pairing already uses.
Validation
bun run check run bun test --timeout 60000 ./server/relay-pc.contract.test.ts ./server/relay/service.contract.test.ts: pass. The new contract test runs two isolated servers (mkdtemp state, port 0) and a local Bun relay: B turns the relay on and makes an offer, A adds B throughPOST /api/machines/relay, B's workspaces show up in A's/api/machines/events, A's proxied/api/machines/<id>/sessionand a terminal/ws?machine_id=reach B, a restarted A signs in again with the saved key, B revokes A and A turns toerrorsaying so, and Remove PC deletes the key. A second test refuses a link with no code and a PC already added.bun test src/lib/i18n.test.ts src/lib/relay src/lib/push.test.ts shared/relay-offer.test.ts+ everyconventions.test.ts: pass.bun run typecheck: pass.bun run check fast: pass at b2eb707 (unit: 11290 pass, 0 fail, 36 skip).