返回 DeepSeek-Reasonix
REMOTE_SESSIONS.md
根目录 / docs / REMOTE_SESSIONS.md
1 # Remote sessions
2
3 <a href="../README.md">README</a>
4 &nbsp;·&nbsp;
5 <a href="./REMOTE_SESSIONS.zh-CN.md">简体中文</a>
6 &nbsp;·&nbsp;
7 <a href="./GUIDE.md">General guide</a>
8
9 The remote module (Remote SSH) runs Reasonix on a remote host and reaches it
10 over your own SSH connection — VS Code Remote-SSH style. This document
11 describes the whole system: what runs where, host configuration, the CLI, the
12 remote serve process, the session lifecycle, the desktop surface, credential
13 modes, and troubleshooting.
14
15 The screenshots in this guide use the Simplified Chinese desktop UI; the
16 controls and states are the same in other locales.
17
18 ## Contents
19
20 - [What the remote module does](#what-the-remote-module-does)
21 - [What runs where](#what-runs-where)
22 - [Hosts and configuration](#hosts-and-configuration)
23 - [Connecting from the CLI](#connecting-from-the-cli)
24 - [The remote serve process](#the-remote-serve-process)
25 - [Remote session lifecycle](#remote-session-lifecycle)
26 - [Desktop remote work](#desktop-remote-work)
27 - [Credentials and model access](#credentials-and-model-access)
28 - [Connection behavior and failures](#connection-behavior-and-failures)
29 - [Troubleshooting](#troubleshooting)
30 - [Command reference](#command-reference)
31
32 ## What the remote module does
33
34 Reasonix bootstraps a persistent headless `reasonix serve` on the remote host,
35 forwards a local loopback port to it over the SSH tunnel, and then opens the
36 serve web client or an in-app remote session tab through that tunnel. The
37 agent, its tools, and its files all live on the remote host at full fidelity;
38 nothing runs through a lossy file proxy.
39
40 - V1 remote hosts must be Linux or macOS. The local CLI and desktop also run
41 on Windows, but V1 Windows authentication does not support the OpenSSH
42 named-pipe agent; use an identity file or password instead.
43 - There is no local background daemon: the CLI's `connect` is a foreground
44 supervisor, and the desktop holds its own tunnel.
45 - Disconnecting the local side never touches the remote serve — it keeps
46 running and the next connection reuses it.
47
48 ## What runs where
49
50 ```
51 Local side Remote host
52 ────────── ──────────
53 reasonix remote … (CLI) ~/.reasonix/remote/
54 desktop app / separate web window serve-<slug>.{json,token,port,pid,log}
55 │ │
56 ▼ ▼
57 supervised SSH connection ─── SSH tunnel ─── headless reasonix serve
58 (keepalive, backoff reconnect, binds remote 127.0.0.1:0, HTTP + SSE
59 TOFU host keys, SFTP) agent / tools / files all remote
60
61 ▼ local loopback -L forward
62 serve web UI in a browser, or the in-app remote session tab
63 ```
64
65 - **Local frontends**: the `reasonix remote …` CLI; the desktop app (Electron);
66 and serve's own web client (opened in a browser or hosted by the separate
67 web-window child process).
68 - **Transport kernel**: one supervised SSH connection — dial, host-key
69 verification, attaching port forwards, keepalive, and backoff reconnect
70 after a drop. The CLI and the desktop share the same kernel; interactive
71 moments (TOFU confirmation, password/passphrase prompts) surface through
72 callbacks to whichever frontend is driving.
73 - **Remote side**: a headless `reasonix serve` bound only to the remote
74 loopback address; port, auth token, and pid are handed over through files,
75 never exposed on the remote network.
76 - **Data plane**: sessions, tool execution, and file operations all happen on
77 the remote host; the local side only forwards and renders. Remote file
78 browsing and editing go over SFTP, not through serve.
79
80 ## Hosts and configuration
81
82 Hosts live in the user-global `[remote]` section of `config.toml`. Like
83 `[secrets]`, a project `reasonix.toml` cannot inject or override remote hosts
84 — a cloned repo can never steer where Reasonix opens SSH connections.
85
86 ```toml
87 [remote]
88 [[remote.hosts]]
89 name = "gpu-box"
90 host = "203.0.113.7"
91 user = "dev"
92 identity_file = "~/.ssh/id_ed25519"
93 workspace = "~/projects/app"
94 serve_install = "auto" # auto | npm | upload | never
95 credential_mode = "remote" # remote | local-proxy
96
97 [[remote.hosts.forwards]]
98 type = "local" # local (-L) | remote (-R)
99 bind = "127.0.0.1:5432"
100 target = "127.0.0.1:5432"
101 ```
102
103 ### Host fields
104
105 | Field | Meaning |
106 | --- | --- |
107 | `name` | Host name; CLI subcommands refer to it |
108 | `host` / `port` / `user` | Address and login user; port defaults to 22, user to the current user |
109 | `identity_file` | Path to a private key. Only the path is stored; key material is never stored |
110 | `passphrase_env` / `password_env` | Env var names holding the passphrase/password; values live in Reasonix's global `.env` |
111 | `proxy_jump` | Jump chain, OpenSSH `ProxyJump` syntax |
112 | `workspace` | Default remote workspace |
113 | `serve_install` | Remote CLI install strategy: `auto` \| `npm` \| `upload` \| `never` |
114 | `credential_mode` | `remote` (key on the remote host) \| `local-proxy` (desktop holds the key); default `remote` |
115 | `use_ssh_config` | Layer unset fields from `~/.ssh/config` |
116
117 `[[remote.hosts.forwards]]` persists port forwards with the host. `type`
118 selects `local` (`-L`) or `remote` (`-R`). For `-L`, `bind` listens locally
119 and `target` is dialed from the remote host; for `-R`, `bind` listens on the
120 remote host and `target` is dialed locally.
121
122 `[[remote.projects]]` pins remote workspaces into the desktop project tree:
123 `host_id` + `workspace` + `title`.
124
125 ### Credential slots
126
127 When the desktop host form receives a plaintext password or key passphrase,
128 Reasonix stores it in a generated `REASONIX_REMOTE_<hash>_PASSWORD` /
129 `REASONIX_REMOTE_<hash>_KEY_PASSPHRASE` slot in the global `.env` (atomic
130 write with rollback on failure) and writes only the slot name to
131 `config.toml`. Leaving the plaintext field empty preserves the current
132 reference and does not create a slot. Deleting or clearing the host
133 garbage-collects unused generated slots; env var names you configured
134 yourself are never deleted.
135
136 ### Host resolution precedence
137
138 1. Fields set explicitly in `[remote]`;
139 2. the local `ssh -G` resolution (authoritative; covers `Include`, wildcard
140 `Host`, `Match` (including `Match exec`), repeated `IdentityFile`,
141 `ProxyJump`, and `IdentitiesOnly`);
142 3. the built-in `~/.ssh/config` parser;
143 4. defaults (port 22, current user).
144
145 `reasonix remote import` stores the original alias with
146 `use_ssh_config = true` instead of copying a snapshot that goes stale.
147
148 ## Connecting from the CLI
149
150 ### Host management
151
152 ```bash
153 reasonix remote add gpu-box dev@203.0.113.7 --workspace '~/projects/app'
154 reasonix remote import --all # import aliases from ~/.ssh/config
155 reasonix remote test gpu-box # dial + auth + host-key check
156 reasonix remote list # list configured hosts
157 reasonix remote remove gpu-box
158 ```
159
160 ### connect: the foreground supervisor
161
162 `connect` behaves like `ssh -N` plus the serve bootstrap: it establishes and
163 holds the SSH connection, bootstraps the remote serve, forwards the serve
164 port to a local loopback port, and attaches the configured forwards. If the
165 link drops it auto-reconnects with exponential backoff and re-attaches the
166 forwards. Ctrl-C disconnects the local side only — the remote serve keeps
167 running, and the next `connect` reuses it.
168
169 ```bash
170 reasonix remote connect gpu-box --open # bootstrap serve, tunnel, open the URL
171 reasonix remote open gpu-box # same as connect --open
172 reasonix remote connect gpu-box --local-port 18787 --no-serve
173 ```
174
175 `--no-serve` (alias `--forward-only`) establishes forwards only and does not
176 bootstrap serve.
177
178 For a host with `credential_mode = local-proxy`, use the desktop to bootstrap
179 and open the workspace. CLI `remote connect` does not create the
180 desktop-owned reverse credential channel; use `--no-serve` only when you need
181 the configured forwards without a remote session.
182
183 ### Remote serve operations
184
185 ```bash
186 reasonix remote serve start gpu-box
187 reasonix remote serve status gpu-box
188 reasonix remote serve logs gpu-box -n 100
189 reasonix remote serve stop gpu-box
190 ```
191
192 `serve start` refuses hosts with `credential_mode = local-proxy`. The desktop
193 is required to bootstrap the serve and provide its reverse credential channel.
194
195 ### Port forwards and remote files
196
197 ```bash
198 reasonix remote forward add gpu-box -L 127.0.0.1:5432:127.0.0.1:5432
199 reasonix remote forward ls gpu-box
200 reasonix remote forward rm gpu-box 127.0.0.1:5432
201 reasonix remote fs ls gpu-box:'~/projects/app'
202 reasonix remote fs get gpu-box:'~/projects/app/main.go' ./main.go
203 reasonix remote fs put ./patch.diff gpu-box:'~/projects/app/patch.diff'
204 ```
205
206 The `fs` subcommands go over SFTP and do not need serve to be running.
207
208 ## The remote serve process
209
210 One serve per workspace: remote state files are named by workspace slug and
211 never interfere with each other.
212
213 **Bootstrap flow** (run automatically by `connect` or when the desktop opens
214 a remote project):
215
216 1. Try to reuse a running serve — it counts as alive only if the pid and the
217 launch arguments match exactly, which defeats pid-reuse misjudgment.
218 2. Probe the remote platform and binary (see the install ladder).
219 3. Generate a fresh auth token: written to `.token.next` first, then renamed
220 atomically, so no reader ever sees a half-written token.
221 4. Launch `reasonix serve` detached via `setsid`/`nohup`: bound to
222 `127.0.0.1:0`, token passed through `--token-file` (never in argv, never
223 visible in `ps`), port and pid written to `.port` / `.pid` files.
224 5. Poll the port file, then write the state JSON and establish the local
225 forward.
226
227 **Binary install ladder** (tried in order when
228 `serve_install = "auto"`):
229
230 1. an existing Reasonix binary on the remote host;
231 2. `npm` global install;
232 3. uploading the local same-platform binary to the remote
233 `~/.reasonix/remote/bin/`;
234 4. downloading from the official release.
235
236 Whether a binary is usable is decided by a capability probe, not a version
237 number: an older binary missing any required serve capability is treated as
238 missing and upgraded. `serve_install = "never"` forbids all installation.
239
240 **Remote state files** (remote `~/.reasonix/remote/`): `serve-<slug>.json`
241 (pid, bound loopback address, workspace), `serve-<slug>.token` (0600),
242 `serve-<slug>.port`, `serve-<slug>.pid`, `serve-<slug>.log`.
243
244 **Access URL**: `http://127.0.0.1:<local-port>/#token=<token>`. The token
245 lives in the URL fragment, so it never reaches server logs with a request;
246 older serve builds fall back to the `?token=` query parameter.
247
248 **Stopping**: `serve stop` signals only the process whose pid and launch
249 arguments match exactly; it never kills an unrelated process.
250
251 **Concurrent bootstraps**: clients bootstrapping the same workspace at the
252 same time are serialized by a remote file lock; the lock expires after 60
253 seconds of inactivity.
254
255 ## Remote session lifecycle
256
257 - One serve carries one **foreground session**. Switching to another session
258 leaves a busy turn running detached in the background until it finishes; it
259 is never interrupted.
260 - A session has a single writer (a lease): while another process holds it,
261 resuming that session is refused and the UI reports "session in use".
262 - **Handoff**: a local window on the serve host may take over the foreground
263 session. Serve then degrades to a read-only mirror that forwards the local
264 writer's frames in real time; 30 seconds without a writer heartbeat
265 reclaims the session automatically, and an explicit reclaim is always
266 possible. The desktop remote tab enters spectator mode and shows a reclaim
267 banner.
268 - **Final-format identities**: sessions-v4 sessions hand over through their
269 writer lock rather than the legacy lease. `/takeover` in the CLI (or a
270 local window's startup resume) asks every resident serve to release the
271 identity, then the CLI mirrors its frames while the desktop tab watches
272 read-only. **Take back** (`/reclaim`) returns ownership; the CLI stays
273 alive on the reclaimed conversation and can `/takeover` the same session
274 back directly or `/resume` elsewhere. Serve discovery ignores state files
275 whose recorded process is gone.
276 - **History before activation**: the identity-addressed history endpoints
277 (`/session-history/*` and `/session/open`) answer for any session stored
278 on the serve, not just the bound foreground — persisted history is a cold
279 read that needs no runtime. A desktop remote tab therefore renders the
280 stored transcript while `POST /resume` is still activating the session;
281 the live Follow v2 stream replaces the baseline once the runtime is ready.
282 The live `/transcript/*` protocol still requires the owning runtime and
283 keeps answering 409 otherwise.
284 - The desktop project tree lists the workspace's remote sessions. Selecting a
285 row resumes that exact session in the shared transcript and composer
286 surface; a running turn keeps executing remotely with its state shown in
287 the tree. The desktop holds the SSH tunnel and never mixes local
288 conversation sessions into the remote tab.
289 - **Forking a completed turn**: `GET /fork-targets` lists the foreground
290 session's turns with the reason each one is or is not forkable, and
291 `POST /fork-session` creates an independent child session from one of them
292 without switching the foreground session, moving the lease, or interrupting a
293 running turn. Desktop uses this path when the server advertises
294 `session-fork-targets-v1`; without that capability the tab reports the server
295 as unsupported rather than falling back to `/fork`, which switches the parent.
296 Both requests carry an expected-session header. The read returns the
297 authoritative `source`; creation submits `sourceSessionId`, stable `turnId`,
298 the atomic commit's `boundarySequence`, and a required `operationId`.
299 Refusals use structured `code`, `reason`, and `message` JSON fields.
300
301 The following screenshots show both ends of a handoff. First, the Reasonix
302 window running locally on the remote host confirms taking over an idle
303 session:
304
305 ![The local window on the remote host confirms taking over an idle session](./assets/remote-session-takeover-idle.png)
306
307 After the takeover, the remote-session tab on the connecting desktop becomes
308 a read-only spectator. It continues receiving the live transcript and offers
309 a **Take back** action:
310
311 ![The remote-session tab becomes a read-only spectator and offers Take back](./assets/remote-session-spectator-reclaim.png)
312
313 ## Desktop remote work
314
315 - **Settings -> Remote SSH**: manage hosts — add/edit/remove, scan-import
316 from `~/.ssh/config`, connect/disconnect, view status.
317 - **Add a remote project**: in the project tree's add-project menu choose
318 **Remote connection**. The three-step wizard saves or reuses an SSH host,
319 connects and verifies that the remote OS is supported, then lets you browse
320 and choose a workspace before opening an in-app remote session tab. The
321 key-file button uses the native file picker so the saved identity is always
322 an absolute desktop path.
323 - **Remote explorer**: the status-bar chip or the host row's **Remote
324 explorer** button — browse and edit remote files over SFTP, manage port
325 forwards, start/open the remote workspace.
326 - **Remote session tab**: the same transcript/composer surface as local
327 sessions, with model switching, reasoning effort, plan mode, compaction,
328 fork, skills, background jobs, and the other commands; the tab survives a
329 brief SSH outage while the desktop reconnects in the background.
330 - **Model catalog**: in `remote` credential mode it comes straight from the
331 remote `/models`; in `local-proxy` mode the desktop-configured catalog is
332 shown, filtered to the current provider kind.
333 - **Dialogs**: TOFU fingerprint confirmation, askpass password/passphrase
334 entry, structured connection errors (naming the `known_hosts` file and
335 line), and the takeover reclaim banner.
336 - **Web window**: a separate child process hosts the serve web UI; the login
337 ticket is written to a one-shot 0600 file (valid for 2 minutes) instead of
338 argv, one instance per host.
339
340 ### Desktop walkthrough
341
342 The project-tree add menu places **Remote connection** beside creating a new
343 project and opening an existing folder:
344
345 ![Remote connection in the project-tree add menu](./assets/remote-project-onboarding-menu.png)
346
347 The remote connection wizard shows its three stages on the left: connection
348 configuration, connecting, and choosing a directory. Once SSH is ready, you
349 can jump to a path, show hidden directories, and choose the workspace to open
350 in the current window:
351
352 ![The three-stage remote connection wizard and directory picker](./assets/remote-connect-wizard-directory.png)
353
354 After opening, the remote project and its sessions appear in the project tree;
355 the session keeps the complete transcript, composer, mode and model selectors,
356 status bar, and session metrics:
357
358 ![A remote project, its session list, and the complete desktop conversation surface](./assets/remote-session-desktop-overview.webp)
359
360 ## Credentials and model access
361
362 | | `remote` | `local-proxy` |
363 | --- | --- | --- |
364 | API key location | the remote host's Reasonix config | the desktop machine |
365 | Model-call path | remote serve → provider | remote serve → reverse tunnel → desktop key holder → provider |
366 | Model list source | remote `/models` | desktop-configured catalog (filtered by provider kind) |
367 | CLI | fully supported | `remote serve start` refuses; `remote connect` cannot provide the desktop-owned credential channel. Use the desktop (`--no-serve` remains valid for ordinary forwards) |
368
369 Functional behavior of `local-proxy` mode:
370
371 - The desktop injects a managed `[[providers]]` block into the remote
372 `config.toml`, pointing at the reverse tunnel address with a scoped token;
373 Reasonix maintains that block — do not edit it by hand.
374 - The credential watchdog polls the reverse tunnel every 3 seconds: a missing
375 forward, a failed probe, or port drift triggers a full heal plus a provider
376 reload. The tunnel secret necessarily rotates after every SSH reconnect
377 (even when the port is unchanged), so a reconnect is always followed by one
378 unconditional heal.
379 - The channel recovers by itself after a brief SSH outage; no manual action
380 is needed.
381
382 Typed passwords and key passphrases are cached in memory, so reconnects
383 never re-prompt; a desktop restart requires entering them again.
384
385 ## Connection behavior and failures
386
387 - **Keepalive**: probed every 30 seconds; 3 consecutive misses (10-second
388 timeout each) declare the link dead, tear it down, and redial.
389 - **Reconnect backoff**: full-jitter exponential — starting at 1 s, doubling
390 per attempt, capped at 60 s. A transient failure on the first connect is
391 reported immediately, never retried silently.
392 - **Terminal failures**: authentication failures and host-key errors are not
393 retried; the desktop marks the remote workspace unavailable until a human
394 intervenes. A brief network outage keeps the UI available while the desktop
395 reconnects and re-attaches its forwards in the background.
396 - **Host keys**: verified against your OpenSSH `~/.ssh/known_hosts`
397 (read-only) plus the Reasonix-managed `~/.reasonix/remote/known_hosts`. A
398 first-seen key prompts for trust-on-first-use and is recorded in the
399 managed file; a key that contradicts a recorded one is a hard error naming
400 the offending file and line, never auto-accepted.
401 - **Auth order**: SSH agent → `identity_file` → password / kbd-interactive.
402 - **Jump hosts**: every `ProxyJump` hop verifies its own host key and
403 authenticates with its own credentials; the target host's password is never
404 sent to an upstream hop.
405 - **Forward semantics**: `-L` listeners survive reconnects (connections are
406 refused while detached); `-R` listeners are recreated on every reconnect;
407 when serve moves ports, the local forward is switched atomically to the new
408 address. `remote forward add` warns for a non-loopback bind; a hand-edited
409 TOML rule is applied as written without that warning, so review its exposure
410 explicitly.
411 - **SFTP**: handles rotate with each reconnect; remote file operations fail
412 during an outage and work again once reconnected.
413
414 ## Troubleshooting
415
416 | Symptom | Cause and remedy |
417 | --- | --- |
418 | Host-key conflict; the error names a `known_hosts` line | The remote was reinstalled or its address changed. Verify the line by hand, remove that entry from the named file, and reconnect. Never auto-accepted |
419 | serve will not start | `serve_install = "never"` with no remote binary, or npm unavailable — switch to `upload` or the release download. Check `remote serve logs` |
420 | Suspected incompatible older serve | A failed capability probe upgrades automatically; if needed, `remote serve stop` then reconnect to force a fresh bootstrap |
421 | `connect` stuck bootstrapping | Concurrent bootstraps are serialized by a remote file lock that expires after at most 60 seconds; retry shortly |
422 | Session reports "in use" | Another process holds the session's lease (another window or serve). Exit from that side or wait for the holder to release |
423 | Remote tab switched to spectator mode | A local window on the serve host took over the session; it auto-reclaims after 30 s without a heartbeat, or use the reclaim banner |
424 | `local-proxy` model calls failing | The watchdog heals automatically; confirm the desktop is online and SSH is connected. Never hand-edit the managed remote provider block |
425 | Authentication failure keeps coming back | Auth failure is terminal and never retried. Check the `.env` slots and key passphrase, or switch to the SSH agent |
426 | Windows local side | The CLI and desktop are supported, but V1 cannot use the OpenSSH named-pipe agent; configure an identity file or password. Remote hosts must still be Linux/macOS |
427
428 ## Command reference
429
430 | Command | Purpose |
431 | --- | --- |
432 | `remote add <name> [user@]host[:port]` | Add a host. Flags: `--identity`, `--jump`, `--workspace`, `--use-ssh-config`, `--serve-install`, `--credential-mode`, `--passphrase-env`, `--password-env` |
433 | `remote list` | List configured hosts |
434 | `remote remove <name>` | Remove a host |
435 | `remote import [alias...]` / `--all` | Import aliases from `~/.ssh/config` |
436 | `remote test <name\|user@host>` | Dial + auth + host-key check |
437 | `remote connect <name>` | Foreground supervised connection: bootstrap serve, tunnel, forwards, held until Ctrl-C. Flags: `--workspace`, `--local-port`, `--no-serve`, `--open` |
438 | `remote open <name>` | `connect --open` |
439 | `remote status [<name>]` | Without a name, list configured hosts; with a name, print that host's configured target and workspace |
440 | `remote forward add <host> (-L\|-R) <spec>` | Add a port forward |
441 | `remote forward rm <host> <bind>` | Remove a forward |
442 | `remote forward ls <host>` | List forwards |
443 | `remote serve start\|stop\|status\|logs <name>` | Remote serve lifecycle; `--workspace` selects the workspace, `logs -n` caps lines |
444 | `remote fs ls <name>:<path>` | List a remote directory |
445 | `remote fs get <name>:<remote> [local]` | Download a remote file |
446 | `remote fs put <local> <name>:<remote>` | Upload a file to the remote |
447
448 See also: [Configuration paths](./CONFIG_PATHS.md) (where `config.toml` and
449 `.env` live and how they prioritize) and the [main guide](./GUIDE.md).
450
450 lines MARKDOWN