22 — Remote engines (cloud fleet)
Picasso can run threads on more than one engine: the bundled engine on this Mac plus remote engines you start yourself on another host — a workspace VM, a build box, a devcontainer host. An engine is the apps/desktop-helper process (the engine's host plus the Picasso relay over HTTP+SSE). This is an *attach* story: Picasso never provisions or installs the remote engine.
The fleet lives in Settings › Engines: one "This Mac (bundled)" row plus one row per attached engine, each with a transport badge (SSH tunnel / Tailscale / HTTPS), a status dot from the unauthenticated GET /health probe, and Connect/Disconnect, Edit, Stop engine and Remove actions.
Running an engine on a remote host
On any host with Node and the pinned engine installed, run the same entry the bundled engine runs:
PICASSO_HELPER_TOKEN=$(openssl rand -hex 32) \
PICASSO_HELPER_PORT=4001 \
node apps/desktop-helper/src/main.ts # or the bundled relay/main.mjsEnvironment contract (main.ts):
| Variable | Default | Meaning |
|---|---|---|
PICASSO_HELPER_TOKEN | — | Required bearer token (unless PICASSO_HELPER_DEV=1, dev only). |
PICASSO_HELPER_PORT | 4001 | Port the engine listens on. |
PICASSO_HELPER_HOST | 127.0.0.1 | Bind host. Set a LAN or Tailscale interface IP for direct attach, or keep loopback and reach it through ssh -L / a TLS terminator. |
PICASSO_DSH_PROFILE | web | The engine profile it boots. |
DSH_HOME | ~/.picasso/dsh-home | The engine home (Picasso's own; under PICASSO_HOME when set). |
A remote engine never receives PICASSO_PARENT_PID / PICASSO_PARENT_STDIN, so nothing watches it: it runs until you stop it (SIGTERM, or Stop engine in Settings which posts POST /shutdown with the token). Run it under tmux/systemd/launchd as you prefer — that is the operator's job, not Picasso's.
Attaching from the Mac
SSH tunnel (recommended — works today)
Add engine → SSH tunnel: name, user@host (or an ~/.ssh/config alias), the remote engine's port (default 4001), and a local port (the sheet suggests a free one). On Connect, Picasso spawns and supervises:
ssh -N -T -L <localPort>:127.0.0.1:<remotePort> <host>with BatchMode=yes, ExitOnForwardFailure=yes, StrictHostKeyChecking=accept-new, keepalives and no askpass — so keys must be loaded in `ssh-agent` or passphrase-free, and the first connect trusts-and-pins the host key (a later mismatch fails hard, as it should). The row stores the engine's endpoint as http://127.0.0.1:<localPort>, so the app's loopback endpoint gate never sees the remote host. If ssh dies the supervisor restarts it with capped backoff (1 s → 30 s, up to 4 restarts) and the row shows the last stderr line.
Direct HTTPS (Tailscale, LAN)
Add engine → Direct: the engine's own https://host:port URL. The relaxed scope gate requires HTTPS off-loopback — a raw http:// LAN endpoint sends the bearer token in the clear and is refused. On Tailscale the usual recipe is a TLS terminator in front of the loopback engine:
tailscale serve --bg 4001 # https://<host>.<tailnet>.ts.net → 127.0.0.1:4001then add https://<host>.<tailnet>.ts.net — the row badges it Tailscale. A loopback direct URL (http://127.0.0.1:4101, e.g. a tunnel you run yourself) is also accepted — that is how the UI-test fixture attaches. A non-loopback direct entry requires the sheet's "I understand the access token crosses this network" confirmation.
Security notes
- Tokens are Keychain-only. The fleet stores engine records (id, name, URL, transport) in
UserDefaults (picasso.fleet.engines); the bearer token is written to Keychain as picasso.engine.<id> and never to defaults, logs or diagnostics.
- Bearer token on the wire. SSH tunnel and HTTPS cover it.
PICASSO_HELPER_HOSTon a
non-loopback bind logs a stderr warning for exactly this reason.
- Stop engine is remote kill.
POST /shutdownstops the engine for every attached client —
fine for a single-operator fleet; revisit before engines are ever shared (B09).
- Unmanaged rows. Remote engines have no local process, so Settings offers no Restart, Show logs
or signature diagnostics on them — those belong to the bundled engine's card.
What is pinned where
Each thread remembers the engine that created it. POST /threads {cwd, …} on a remote engine resolves cwd on *that* host — files, terminals and git routes the engine serves then correctly operate on the remote checkout, and must not be offered to local filesystem surfaces. Thread→engine routing inside ThreadsService (per-engine clients and stream pairs, (engineId, sessionId)-keyed state) is the B06-track refactor — see the integration notes in research/prd/track-b/B08-cloud-fleet.md.