Skip to content

feat(machines): add a PC through the relay, without SSH - #2829

Merged
devswha merged 6 commits into
mainfrom
feat/relay-machines
Nov 5, 2026
Merged

devswha merged 6 commits into
mainfrom
feat/relay-machines

Conversation

@devswha

@devswha devswha commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

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

  • This PC's server pairs with the other PC's Ovis exactly as a phone does: same pairing link and one-time code, Noise NK, Ovis Mux v1, a P-256 device key. The other PC needs no change: it sees one more paired device, Ovis on <hostname>, and can revoke it.
  • server/relay-pc.ts opens that connection and serves it on 127.0.0.1:<random port> behind a random bearer token. That is the same { url, token } endpoint an SSH local forward gives, so MachineManager'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 in relay-pcs.json with their relay id (apart from machines.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 to error and stops retrying. Bridge updates and setup jobs are refused for relay PCs (they run their own Ovis).
  • The key is kept in <stateDir>/relay-pcs/<pc id>.json (0600) only after the other PC admitted it; Remove PC deletes it.
  • The relay client core (openRelayChannel, device-key signing/pinning) moved from src/lib/relay/ to shared/relay-channel.ts and shared/relay-device.ts, since server code may not import src/ (remote bundle boundary). src/lib/relay/channel.ts and device.ts keep thin wrappers that default to the IndexedDB store, so the hosted app is unchanged.

Wire changes (additive)

  • New POST /api/machines/relay { link, name? } → 201 { machine }, behind the usual PC-change guards (same origin, X-Herdr-Machine: 1, watch devices refused).
  • Machine.kind gains "relay"; new optional Machine.relay { server_id, relay }.
  • Nothing changes between PCs or on the 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 through POST /api/machines/relay, B's workspaces show up in A's /api/machines/events, A's proxied /api/machines/<id>/session and a terminal /ws?machine_id= reach B, a restarted A signs in again with the saved key, B revokes A and A turns to error saying 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 + every conventions.test.ts: pass.
  • bun run typecheck: pass.
  • bun run check fast: pass at b2eb707 (unit: 11290 pass, 0 fail, 36 skip).
  • Reviews: Codex (terra, xhigh) and omo; findings and resolutions are in the PR comments. omo review at b2eb707: CLEAR.
  • Screenshots of the new row at 1280 and 390 px taken from an isolated preview server.

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.
@coderabbitai

coderabbitai Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Explain Change →

Note

CodeRabbit ASCII loading animation

Currently processing new changes in this PR. This may take a few minutes, please wait...

Review info

Run configuration

Commits

Reviewing files that changed from the base of the PR and between 029780f and 69006da.

Files selected for processing: 25

  • changelog.d/relay-remote-pcs.md
  • docs/remote-pcs.md
  • server/core/status.ts
  • server/index.ts
  • server/machine-api.ts
  • server/machines.ts
  • server/relay-pc.contract.test.ts
  • server/relay-pc.test.ts
  • server/relay-pc.ts
  • shared/machines.ts
  • shared/relay-channel.ts
  • shared/relay-device.ts
  • shared/relay-offer.ts
  • src/components/MachineSidebar.tsx
  • src/components/SettingsDialog.css
  • src/components/SettingsDialog.tsx
  • src/lib/api.ts
  • src/lib/i18n/machines.ja.ts
  • src/lib/i18n/machines.ko.ts
  • src/lib/i18n/machines.zh.ts
  • src/lib/i18n/settings.ja.ts
  • src/lib/i18n/settings.ko.ts
  • src/lib/i18n/settings.zh.ts
  • src/lib/relay/channel.ts
  • src/lib/relay/device.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

- 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
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

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.

# Finding Severity Resolution
1 Removing a PC during a sign-in could write its key back HIGH Fixed: RelayPcKey writes only while the PC is still this server's (same runtime, same generation). Unit test writes no key for a PC removed while its pairing finished.
2 A cancelled request waiting for a stream place could still be sent HIGH Fixed: the wait observes the request's abort signal, and an already-aborted request resets its stream at once.
3 The pairing link (one-time code) was accepted over plain LAN HTTP HIGH Fixed: POST /api/machines/relay needs HTTPS or localhost, as SSH passwords do (canSendSecret, 403 https_required).
4 A socket could queue unbounded input before the other PC accepted its stream MEDIUM Fixed: 1 MiB before acceptance, then close 1009; the wait is cancelled when the local socket closes.
5 Output to a slow local socket was not bounded MEDIUM Fixed: as this PC's own sockets, a failed send or more than 1 MiB buffered closes with 4008.
6 An add whose machines.json save failed left a live tunnel MEDIUM Fixed: the PC, its tunnel and its key are rolled back and the error is returned.
7 A single WebSocket frame over 1 MiB (a very large roster) ends the stream MEDIUM Not changed: the mux's 1 MiB message limit already applies to every phone through the relay (docs/relay.md Limits). Not reproduced here; left as a known limit of the relay.

Validation at 0943f83: bun run check fast pass (unit 11274 pass, 0 fail); server/relay-pc.contract.test.ts (two isolated servers through a local relay) and server/relay-pc.test.ts pass on the isolated herdr session.

- 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
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

omo review of a0de610 (BLOCK) and how each finding was handled in 6725d41.

# Finding Severity Resolution
1 Any request to a relay PC slower than 10 s failed with 502 (Bun's default idleTimeout on the loopback forward) HIGH, reproduced Fixed: the forward sets idleTimeout: 0; the machine proxy's own deadlines (75 s, 165 s for slow routes) bound each request. New contract test waits for a PC that answers slower than an idle connection's default limit (B's replies delayed 12 s): 502 without the fix, 200 with it.
2 A single WebSocket frame over 1 MiB ends the stream MEDIUM, unreproduced Not changed: the mux's 1 MiB message limit already applies to every phone through the relay; same as Codex #7.
3 The client mux resets HTTP streams after 60 s by default LOW Fixed: the forward's channel passes requestTimeoutMs: () => 0 (new optional RelayChannelOptions.requestTimeoutMs), so the proxy's 165 s routes are not cut at 60 s.
4 An add that fails after admission leaves a paired device on the other PC LOW Partly: the failed-save error now says to revoke Ovis on <host> under Phone & devices on that PC. The duplicate-after-admission case (two adds of one link at once) only happens in a race and is left as is.
5 remove() could leave the key on disk if deleting it failed after the registration was saved LOW Fixed: the key is deleted before the registration is saved.
6 No test for slow requests through the tunnel LOW Fixed by the test in #1.

Validation at 6725d41: bun run check fast pass (unit 11284 pass, 0 fail); server/relay-pc.contract.test.ts 3 pass on the isolated herdr session.

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.
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

omo re-review of 6725d41 (BLOCK) and how it was handled in b2eb707.

# Finding Severity Resolution
1 A release from before relay PCs refuses a machines.json holding one, so a rollback would not start HIGH, reproduced Fixed: relay PCs are saved in <stateDir>/relay-pcs.json; machines.json keeps its old shape. The contract test asserts the relay PC is in relay-pcs.json and not in machines.json, and a restart still loads it.
2 A PC slower than 15 s on /api/session gets reconnected MEDIUM, unreproduced Not changed: the same roster deadline every SSH PC has.
3 32 long requests filling every stream place would time the roster poll out MEDIUM, unreproduced Not changed: no realistic path (terminals are capped at 8 WebSockets, the browser's event stream stays on this PC).
4 A key write that fails after admission gives no revoke hint LOW Not changed (disk full or unwritable state dir).
5 Two concurrent adds with two links for one PC leave an extra device there LOW Not changed: a race only; the second add says the PC is already added.
6 A transient key read error (EIO, EACCES) stopped the PC for good LOW Fixed: only a missing or damaged key stops retries; other read errors are retried.

Validation at b2eb707: bun run check fast pass; server/relay-pc.contract.test.ts 3 pass on the isolated herdr session; server/machines.test.ts pass.

@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

omo review at b2eb707: CLEAR (receipt at this head). Remaining notes from it, not changed in this PR (the one fix round is used):

  • MEDIUM, reproduced with a disk failure: a failed machines.json write during an add leaves the PC in relay-pcs.json: follow-up Relay PC: a failed save while adding leaves its relay-pcs.json entry #2833.
  • MEDIUM, unreproduced: with every stream place of the relay session busy, the 15 s roster refresh could time out and reconnect the PC. No realistic path found (terminals are capped at 8 WebSockets; the browser's event stream stays on this PC).
  • LOW, unreproduced: a remote snapshot frame over 1 MiB (the relay's WebSocket message limit, the same for phones) would close the observer socket and reconnect.
  • LOW: a relay PC signs in as a paired device, so the other PC's sidebar hiding rules apply to what this PC sees of it; an SSH bridge sends the full snapshot.

The PR body is updated: relay PCs are stored in relay-pcs.json.

Two buttons named Add PC in one dialog are ambiguous for a screen reader
and for scripts/ui-regression.ts.
@devswha

devswha commented Nov 5, 2026

Copy link
Copy Markdown
Owner Author

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.

@devswha
devswha merged commit 2a96234 into main Nov 5, 2026
19 of 20 checks passed
@devswha
devswha deleted the feat/relay-machines branch November 5, 2026 01:46
devswha added a commit that referenced this pull request Nov 5, 2026
…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.
devswha added a commit that referenced this pull request Nov 5, 2026
)

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).
devswha added a commit that referenced this pull request Nov 5, 2026
…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.
devswha added a commit that referenced this pull request Nov 5, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant