Skip to content

Machines: view and control another computer's localhost over SSH - #67

Merged
marcellosso merged 28 commits into
mainfrom
feat/machines
Nov 5, 2026
Merged

marcellosso merged 28 commits into
mainfrom
feat/machines

Conversation

@marcellosso

@marcellosso marcellosso commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

#66, finished: the macOS app and the Linux/Windows app view and control another computer's localhost (a Machine) over the system's ssh, with Forwards for Open in Browser. Creating and editing Projects on a Machine is #68, separate.

Summary

sequenceDiagram
    participant App as App (Machines)
    participant SSH as ssh -M (ControlMaster)
    participant Relay as devbox: localhost remote --stdio
    participant Core as devbox shared core
    App->>SSH: ssh -T -M -S <0700 folder> devbox localhost remote --stdio
    SSH->>Relay: Machine protocol (JSON lines, versioned on its own)
    Relay->>Core: Client::Machine, changes as Origin::Machine
    Core-->>Relay: Projects, statuses, events, logs (no Secret values)
    App->>SSH: ssh -O forward -L 127.0.0.1:P:localhost:P (+ [::1])
Loading

Machines in the core now keeps each Machine's Projects and statuses across reconnects and publishes them in one stream, so both apps mirror one subscription instead of re-subscribing per connection:

 Machines
+  add(name, destination)        connects first; saved only once reached
+  rename / reconnect / remove
   connect / disconnect / wake
+  subscribe() -> [MachineSnapshot], MachinesEvent
+    MachinesChanged | StateChanged | ProjectsChanged | StatusesChanged | ServiceChanged
+  start / stop / restart / start_all / stop_all (machine_id, …)
+  subscribe_to_run / subscribe_to_services
+  open_service(machine_id, service_id)   forwards the Port to the same port here, or PortConflict naming who holds it
 MachineState::summary / needs_user     worded once, used by both apps
 ssh_config_hosts()                     Host names of ~/.ssh/config, following Include, no patterns
 core/localhost-core/src/
 ├── machine/
 │   ├── machines.rs        + the view per Machine, add-then-save, rename, controls, Open
+│   ├── ssh_config.rs      # ~/.ssh/config hosts, read only
 │   └── ssh.rs             + user@host:port → ssh://…, PortConflict names the local holder
 ├── listener.rs            + holding(port)
 └── ffi/                   + Machines object, MachinesEvents, ssh hosts, state wording
 apps/macos/
 ├── LocalhostCoreFFI/…/Machines.swift   # typed calls, LogSource, MachinesStore (main-actor mirror)
 └── App/                  Settings › Machines, sidebar section per Machine, logs, Open via Forward
 apps/desktop/
 ├── src-tauri/src/machines.rs           # commands + a CoreView-shaped view per Machine
 └── src/                  machines.ts store, Sidebar sections, Settings › Machines, place-aware actions/shortcuts

In both apps a Machine's Project rows look and act like local ones (Start, Stop, Restart, Start All, logs, Open in Browser), dimmed and disabled while it isn't connected, and identity is (Machine, UUID). Editing actions are hidden for now (#68). Settings › Machines: an SSH host field suggesting ~/.ssh/config hosts (or user@host:port), an optional Name, Add connects first and shows the MachineProblem with its next step; each row shows the destination, its state, and ⋯ Reconnect / Rename / Remove. Settling Services are announced on both platforms; quitting asks when Services started from here on a Machine are active.

When localhost on a Machine starts but can't serve (e.g. its shared core is an older version's, kept by an agent's localhost mcp), it says why with a refused line instead of the hello, shown as MachineProblem::CantServe in that Machine's words rather than as an SSH failure; older apps still see the connection end. The shared core's version clash is now a typed LocalhostError::IncompatibleCore with a next step, here and on the Machine. While adding, a problem names the host it tried.

Also, from trying it: the first-run empty state's three actions are stacked at one width, as they overflowed the column on the desktop app and squeezed on the Mac.

Also: MachineProblem::Other (whatever SSH says that localhost doesn't recognize, e.g. a ~/.ssh/config mistake) is shown once and waits for Reconnect instead of retrying forever; a reset or closed connection counts as Unreachable and is retried.

Evidence

  • Before: apps couldn't show a Machine; bindings.ts was stale; Other looped every 30 s forever; machines_ssh skipped in CI.
    After:

    machines_ssh (private sshd on loopback, real `localhost remote`)
      shows_another_machine_as_the_apps_do   add → ProjectsChanged → Connected → start → Running (Origin Machine)
                                             open_service → PortConflict{port, owner: python3} (both ends are this box)
      connects_again_after_the_connection_is_lost, controls_…, forwards_…, says_why_…   ok
    machine unit tests
      adds_a_machine_only_once_it_is_reached      HostNotFound, nothing saved
      shows_what_ssh_says_once_instead_of_retrying_it   1 attempt, Unavailable{Other, is_retrying: false}
      lists_named_hosts_following_includes, gives_ssh_a_port_in_the_form_it_reads, forwards_only_urls_local_to_the_machine, …
    src-tauri: shows_each_machine_as_this_computer_is_shown; bindings.ts regenerated
    desktop: machines.test.ts, Sidebar (Machine sections, Reconnect), shortcuts on a Machine
    

    Log view on WebKitGTK (CI, 4 Services × 1000 lines/s × 30 s, 3 rounds; the first attempt hung on the runner and passed on a rerun, and it ran normally here with the same settings):

    build      p50 ms  p95 ms  frames > 33 ms  average MB
    baseline   16      21      9–15            732–740
    this       16      20–22   5–12            731–773
    

    cargo test, cargo clippy --all-targets (also --features ffi and --target x86_64-pc-windows-msvc), cargo fmt --check, pnpm typecheck, pnpm test (103), pnpm build, and src-tauri cargo test + clippy all pass on Linux.

    Built and tried on the Mac against the dev box over Tailscale: Add Machine, its Projects in the sidebar, and its problems (NotInstalled for a host without localhost, then CantServe while an old 0.4.0 core ran there). The desktop GUI hasn't been run against a Machine, only its tests.

Merge Danger

Door: two-way, mostly.

PROTOCOL_VERSION 7 → 8: a 0.4.0 client and a core from this branch refuse each other, as with any release, and updates retire the old core. Once released, machines.json (schema 1) and Machine protocol 1 become commitments, though the protocol only grows.

Blast Radius: both apps

AppModel's and the desktop's actions gained a place (default this computer), the selection a machineProject case, and open_log a machine_id argument. Local behaviour should be unchanged, but those paths are touched in both UIs. Each app opens ssh connections at launch for Machines the user added; with none added, nothing new runs.

Left out

  • Network-change detection on Linux/Windows (macOS retries on wake; elsewhere the backoff, up to 30 s, covers it).
  • Machine Projects in the command palette and the menu bar / tray.
  • The quit question counts every active Run started from a Machine on a connected one, so it can overcount when another computer also started Runs there; its wording hedges ("unless localhost is open on them"). Exact counts need the relay to say which Runs this connection holds.
  • port_probe::tests::sees_a_listener_on_either_loopback_address failed once in a full run here and passed alone 5/5: it asserts a freed ephemeral port is closed, which a parallel test can rebind. Pre-existing, not touched.

Also carries the docs and website for Machines (#70, for #69): README, site/ docs and guide, the landing page, and the 0.5.0 changelog entry.

Closes #66

@vercel

vercel Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
localhost-web Ready Ready Preview Nov 5, 2026 3:43am UTC

…der doesn't exceed a Unix socket's path limit
…ts, add a Machine only once it is reached, and open its Services through a Forward
… through a Forward, with the hosts of ~/.ssh/config
…place, and MachinesStore, a main-actor mirror
…section each, their logs, and Open through a Forward
…, a sidebar section each, their logs, and Open through a Forward
… closure, keep folder commands to this Mac, and reopen a Machine's log once it reconnects
@marcellosso marcellosso changed the title Machines: view and control another computer's localhost over SSH (core) Machines: view and control another computer's localhost over SSH Nov 5, 2026
Document Machines: docs page, guide, README, landing page and changelog
@marcellosso
marcellosso merged commit 0ac7595 into main Nov 5, 2026
16 checks passed
@marcellosso
marcellosso deleted the feat/machines branch November 5, 2026 03:56
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.

Machines: see and control Services on another computer over SSH

1 participant