Plan: Milestone 11 - Remote. Design: Remote and Server Modes.

Steps 1–6 done (2026-09-21) — verified with a fake ssh, the real server binary, a self-signed certificate, and the release desktop app on the KDE session (screenshot below)

File → Open Remote Folder… (or moonkale --ssh host:/path): the system ssh runs in a terminal tab (keys, agent, passwords, host-key prompts exactly as in a shell), copies moonkale-server to ~/.local/share/moonkale/server/<version>/ on the host the first time, starts it on the host’s loopback with a per-session token over stdin, forwards its port, and the desktop becomes that server’s client: the folder, its index, LSP, git, the terminal and Typst run there, the editor, the graph and the LLM keys stay here. The status bar reads ⇅ host:path · connected; closing the folder (or Disconnect Remote) kills ssh and with it the remote server. Nothing is written to disk on either side but the binary. Standalone server: moonkale-server --port --bind --root --token-stdin --version; MOONKALE_TERMINAL=0 refuses shells; cross-origin websocket upgrades get 403; TLS built in (MOONKALE_TLS_CERT/KEY), and a non-loopback bind is refused without it unless MOONKALE_INSECURE_HTTP=1. New crate moonkale-remote; api::client; ext-api::remote. Tests: the whole session against a fake ssh (0.9 s), the workspace side in a VirtualDom, server.mjs against the real binary (6 checks), desktop/tests/remote.rs against a running server; 4 browser suites re-run; clippy/fmt clean.

Steps as executed

#stepoutcomenotes
1api::client — connect(url, token, label) / disconnect / active, the client-side WorkspaceConfig callbacks (sources, terminal, LSP, git, Typst, wasm) shared by the web client and the desktop; session_token(); desktop dispatchers check active(); MOONKALE_REMOTE + MOONKALE_TOKEN at start✅ desktop/tests/remote.rs (ignored) against the E2E server on 8090 and the token server on 8091: open root, read a .md, attach by descriptor, git status, wasm list; refused without the bearerapi.md, desktop.md
2server flags --port --bind --root --token-stdin --version (in web/src/main.rs, server feature); MOONKALE_TERMINAL=0 → Exit with a message; auth::same_host + 403 on cross-origin websocket upgrades✅ server.mjs: token over stdin (and not in /proc/<pid>/cmdline), 401/200, terminal off, cross-origin 403 / same-origin 101the binary: cd packages/web && dx build --platform server [--release] → target/dx/web/<profile>/web/server
3moonkale-remote: SshSession::open — control-master ssh under a PTY with the port forward and a remote sh script (probe → wait for upload → token with echo off → exec moonkale-server --token-stdin), upload over the multiplexed socket, wait_for_server, api::client::connect; TeeBackend (PTY → terminal tab + session reader); PtyBackend::{spawn_args, kill, is_running}✅ tests/shim.rs (ignored): fake ssh on PATH → Uploading → Starting → Ready, binary in the scratch HOME, open_folder + fetch_text through the session, close disconnects; with the release binary 0.9 s, with the 1.7 GB debug binary 3 sremote.md
4ext-api::remote (RemotePhase, RemoteSession, RemoteHosts, RemoteState), Workspace::{open_remote, close_remote, adopt_terminal, is_remote_source, remote_hosts}, Command::{OpenRemote, CloseRemote}; terminal panel adopts pushed sessions; ui::remote_dialog (hosts from ~/.ssh/config), File menu, palette remote.open/close, status item, Explorer icon; --ssh host:/path / MOONKALE_SSH at start✅ ext-api/tests/remote_flow.rs (a VirtualDom with a fake OpenRemote: prompt → status + terminal shown; ready → open_folder once, source flagged remote, recents untouched; close → session closed once, sources and documents gone); ssh_hosts_in unit test; the dialog itself needs a desktop display — Daniel’s lookext-api.md, ui.md
5auth::tls_files, bind_mode(ip, token, tls, insecure_ok); serve_tls in web/src/main.rs (axum-server + rustls/ring on the IP/PORT address)✅ server.mjs: --bind 0.0.0.0 with a token but no TLS → exit 2 with the reason; MOONKALE_INSECURE_HTTP=1 starts; with a self-signed cert HTTPS answers 200 (bearer) / 401, plain HTTP on that port does not; 4 bind_mode unit cases
6verify, log, vault✅ terminal, panels, menubar, palette browser suites re-run (the terminal panel and the menus changed); server added to run-all.sh; this note, crate notes, Problem Log P-095–P-097, Roadmap, Home, Project Structure, Remote and Server Modes

The release desktop app after moonkale --ssh fake-host:… against a fake ssh: the remote folder in the Explorer with the remote icon, the ssh fake-host tab showing the server’s audit lines, ⇅ fake-host:… · connected in the status bar.

What the user sees

  • File → Open Remote Folder…: a host typed the way you would after ssh — SSH_AUTH_SOCK=0 -p 443 -v daniel@192.168.178.62, -i ~/.ssh/MathStruct daniel@dtrmblog.de, an alias from ~/.ssh/config (offered), ssh://me@host:443 — and a folder path. Leading VAR=value words become the ssh process’s environment, the words before the last are its options, the last word is the destination; the same for --ssh "SSH_AUTH_SOCK=0 -p 443 daniel@192.168.178.62:/home/daniel/Code". (A Host block in ~/.ssh/config with Port 443, IdentityFile, IdentityAgent none does the same without typing it each time.) Connect → a terminal tab ssh <host> opens where ssh asks whatever it asks; the status bar goes connecting… → uploading the server… → starting the server… → connected, and the folder appears in the Explorer with the remote-folder icon. Everything then works as with a local folder — including a New Terminal, which is a shell on the host.
  • The first session per host and version copies the server (142 MB stripped release build; a few seconds on a LAN, minutes over a slow link); later sessions skip it.
  • Disconnect Remote (File menu / palette) or closing the folder ends the session; the ssh tab shows the server’s last lines and can be closed.
  • moonkale --ssh build-box:/srv/code (or MOONKALE_SSH=…) opens the session at start.
  • On a server you expose yourself: MOONKALE_TLS_CERT=cert.pem MOONKALE_TLS_KEY=key.pem MOONKALE_TOKEN=… ./moonkale-server --bind 0.0.0.0 --port 8443 --root /srv/code; without the certificate it refuses to start off loopback; MOONKALE_TERMINAL=0 for an editing-only server.

Deviations from the plan

  1. The host field is a whole ssh command line minus the word ssh, not just a host name: Daniel’s real hosts need -p 443, -i <key> and SSH_AUTH_SOCK=0 (SshTarget::parse, added the same day; options and environment reach the master ssh under the PTY and the upload ssh -S alike — the shim test demands both).
  2. ControlMaster is used after all (-M -S <socket> -o ControlPersist=no): the upload needs a second authenticated channel and a second password prompt would be wrong; the socket lives in a per-process temp dir and dies with the master, which Moonkale kills on close — the plan’s worry (sockets outliving Moonkale) does not apply.
  3. No checksum on the upload yet, and no arch check beyond logging uname -m: the binary travels over the authenticated, encrypted channel and lands through an atomic mv; a wrong architecture fails to exec and the terminal tab shows why. A signed release process (Milestone 12 candidate) brings the checksum.
  4. File → Connect to Server… (URL + token) is not a dialog yet — the desktop-as-client path exists (MOONKALE_REMOTE) and is what the SSH session uses; a dialog is a small follow-up once someone runs a LAN server.
  5. One server at a time: while a session is connected every open_folder goes to the remote (the server-function URL is process-global); local folders come back after Disconnect. Mixing local and remote sources is the Projects and Sources work.
  6. Cross-compiled builds (aarch64-unknown-linux-gnu) were not produced: the upload takes whatever server_binary() finds (MOONKALE_SERVER_BINARY, next to the executable, or the dev build); the release process decides how a desktop bundle carries a server for another architecture.
  7. The server binary is large — 188 MB release, 142 MB stripped, 1.7 GB debug (DuckDB, wasmtime, tree-sitter, Typst) — and needs public/ next to its cwd (dioxus-server asserts the directory; the remote script creates an empty one; the remote server serves only the API).
  8. The dialog itself was not clicked (no input injection), but once a display was available the whole session ran in the release desktop app via MOONKALE_SSH: the ssh tab showed a password prompt with “answer in the terminal” in the status bar (first run, prompting shim), then with a prompt-free shim upload → start → connected → the folder in the Explorer. That run found P-098 — dioxus’s server URL is a OnceLock, so switching servers at run time needs the loopback relay (api::relay) the desktop now installs before launch; without a display the shim test had set the URL first and never hit it.

Problems hit (→ Problem Log)

  • P-095 the session token was echoed by the PTY: the script printed the prompt before stty -echo, and the desktop answered faster than the shell could switch echo off → the order is now stty -echo; echo MOONKALE_TOKEN?; read.
  • P-096 dioxus-server panics without a public/ directory next to the cwd (Couldn't read public directory) — the remote script creates an empty one.
  • P-097 no display for the desktop app at first (Xorg on :0 was sddm’s login screen; no Xvfb) — the workspace logic got a VirtualDom harness test (ext-api/tests/remote_flow.rs); later in the day Daniel switched the display on permanently and the app ran (P-099 has the launch recipe).
  • P-098 set_server_url panics after launch (a OnceLock) → api::relay, a loopback TCP relay the server functions always point at.
  • P-100 (found by Daniel on the same day) the graph’s zoom floor of 0.05 hid a 2 851-node graph: MIN_SCALE 0.002 and a fit on every layout frame — graph-render.md.
  • P-099 launching the app from a script: env -i with the session’s own display variables; the GTK Wayland backend fails under an inherited shell environment and WebKit’s DMA-BUF renderer fails under Xwayland here.

Numbers

  • moonkale-remote: 3 files, ~480 lines; ext-api::remote 90 lines; workspace additions ~170 lines; dialog 90 lines.
  • Session to a fake host with the release binary: 0.9 s end-to-end (upload included, local disk).
  • Server binary: 188 MB release (142 MB stripped), 1.7 GB debug; release build 229 s. Desktop app: 193 MB release (147 MB stripped) + 6.9 MB assets; release build 361 s.
  • Desktop RSS with a remote folder open: moonkale 202 MB (48 MB anonymous, the rest the binary’s own pages) + WebKit web process ~485 MB (240 MB anonymous) + network process 61 MB — see Why Not a Plugin or Electron for the breakdown.
  • Tests: 3 native suites new (remote unit, remote/shim, ext-api/remote_flow), server.mjs (6 checks), 4 browser suites re-run; 34 browser suites in run-all.sh.

Same-day follow-ups (specs 020–023, from Daniel’s first day with the build)

  • 020 every open folder in one graph, coloured per folder, picker narrows to one (graph.mjs step).
  • 021 markdown opens in Rich mode (editor.markdown_rich), and loading no longer dirties the file (P-102; rich.mjs step). The E2E fixture turns Rich off in .moonkale/settings.json (and session.mjs in the user scope) because the suites drive the source editor.
  • 022 Julia and Python symbols in the index (tree-sitter-julia, tree-sitter-python; unit tests).
  • 023 the 30-minute build after a pull: two gigabyte debug binaries and feature changes (P-101); [profile.dev] debug = "line-tables-only" + no dependency debug info — clean debug build 399 s, binaries 449 + 470 MB; the agent’s cargo runs now use CARGO_TARGET_DIR=target/agent. serve.sh waits for the server to answer instead of dx’s banner (which appears before the build ends).
  • Full browser batch: 34/34 pass.
  • Daniel’s first real session got stuck at MOONKALE_NEED_UPLOAD → a private sshd on this machine (nushell as the login shell — the harshest case) reproduced four real-ssh problems the shim could not: P-103 login-shell quoting (base64-wrapped commands), P-104 no remote PTY (token echoed, server lingering — -t), P-105 port collisions (random port below the ephemeral range), P-106 no server binary found when dx runs the app from its bundle dir, failing silently (lookup to the workspace root; failures printed into the ssh tab and the session ended). real_sshd_session_reaches_ready now covers the real path; the release app launched from its bundle dir uploads and connects.