Skip to content

SSH Remote Projects, Terminals & Files

dazeb edited this page Sep 17, 2026 · 2 revisions

SSH Remote Projects, Terminals & Files

A project can point at a remote host instead of a local directory. When it does, terminals, git operations, and file operations are not reimplemented per feature: they all run through one SSH transport seam (src/core/ssh.ts) and a small set of remote adapters on top of it. This page covers the remote project model, the transport primitive, and the three SSH-backed implementations — PTY/tmux, git, and files — plus the call chains, state, and quoting/boundary rules that hold them together.

Module map

Module Role
src/core/ssh.ts Transport seam. Destination parsing, ssh argv construction, ControlMaster multiplexing, remote-shell quoting, and the three runSsh* executors. Electron-free, no local shell.
src/shared/remote-project.ts Pure ProjectRemote contract: isRemoteProject, remoteLabel, normalizeRemote. Used by both main and renderer.
src/core/remote-project.ts Namespace re-export of the shared helpers plus the ProjectRemote type.
src/core/remote-pty.ts Remote terminal transport: ssh -tt + remote tmux create/attach/capture/kill, in async and sync flavors.
src/core/remote-file.ts Remote read/list/write built from fully quoted compound commands and stdin piping.
src/core/remote-git.ts Remote mirror of the local git-service surface via git -C <remotePath>.
src/core/pty-manager.ts Session owner; decides remote vs. local spawn and carries the tmux fresh semantics.

Everything above is Electron-free (child_process, fs, path, shared types only), which is what allows the same remote code to run in Electron main and in the Server Edition.

Transport seam: src/core/ssh.ts

Three shapes define the contract:

  • RemoteHost { host: string; user?: string; port?: number }
  • SshResult { code: number; stdout: string; stderr: string }
  • SshOptions { controlPath?: string }

Destination parsing

parseRemote accepts host, user@host, and user@host:port. A trailing :segment is treated as a port only when it is all digits, so scp-style host:path keeps the path attached to the host instead of being misread as a port.

sameRemoteHost compares host, user, and port with defaults normalized (root, 22). It deliberately does not compare path, because a project's git root may differ from its cwd.

Connection args and ControlMaster

connectionArgs always emits:

  • -o BatchMode=yes
  • -o StrictHostKeyChecking=accept-new
  • optional -o ControlMaster=auto -o ControlPath=<path> -o ControlPersist=600 when SshOptions.controlPath is set
  • -p <port> when present
  • the trailing user@host target

sshControlPath(userDataPath, remote) derives a stable per-host socket path: it builds the label user@host:port, sanitizes it with /[^A-Za-z0-9_.@-]/g → '_', ensures <userDataPath>/ssh/ exists, and returns <userDataPath>/ssh/ctl-<safe>. The source documents the intent: repeated git/file calls over a WAN stop paying the TCP+auth handshake per call, while interactive terminals deliberately do not pass controlPath because they own a dedicated connection.

Executors

Function Input Behavior
runSsh(remote, command: string[], opts?) argv array Serializes via remoteCommand, then delegates to runSshRaw.
runSshRaw(remote, commandStr, opts?) pre-quoted single string spawn('ssh', [...connectionArgs(remote, opts), commandStr]), buffers stdout/stderr as strings, resolves on close with code ?? 1, and converts spawn error into { code: 1, stdout: '', stderr: err.message }. Never rejects.
runSshWithInput(remote, commandStr, input, opts?) string + stdin payload Same as raw but with stdio: ['pipe','pipe','pipe']; calls child.stdin.end(input) after wiring listeners. Used by remote file write.

Quoting

The local side never runs a shell — arguments go straight into spawn. But ssh joins argv elements with spaces into one string that the remote shell parses, so every dynamic value must be quoted for that remote shell:

  • shq(s) wraps in single quotes and replaces an embedded ' with '\''.
  • remoteCommand(command) maps shq over each element and joins with spaces.

The source names the failure mode explicitly: an unquoted commit message or path containing spaces would be split into separate words on the remote side. Callers that build their own compound command must instead use runSshRaw, owning the quoting themselves.

flowchart TB
    subgraph MAIN["Main-process remote modules"]
        PM["pty-manager.ts<br/>session owner"]
        RP["remote-pty.ts<br/>ssh -tt + tmux"]
        RG["remote-git.ts<br/>git -C ops"]
        RF["remote-file.ts<br/>read / list / write"]
        SSH["core/ssh.ts<br/>RemoteHost, connectionArgs, shq, runSsh*"]
    end
    PM --> RP
    RP --> SSH
    RG --> SSH
    RF --> SSH
    SSH -->|"spawn ssh, argv array, no local shell"| HOST["remote shell<br/>one joined command string"]
    HOST --> TMUX["tmux new-session / has-session / capture-pane / kill-session"]
    HOST --> GIT["git -C path ..."]
    HOST --> FS["find / cat / mkdir -p"]
Loading

Key nodes: the four remote modules never spawn ssh directly except through the core/ssh.ts helpers; remote-pty.ts additionally reuses connectionArgs and remoteCommand for its spawnSync variants. The remote shell is the only place a command string is interpreted, which is why shq/remoteCommand exist at all.

Remote project model

src/shared/remote-project.ts is pure — no Electron, no fs — so the renderer can use it, and src/core/remote-project.ts is only a re-export plus the ProjectRemote type import.

  • isRemoteProject(project) returns true when project.remote != null. The doc comment ties this to cwd being null for remote projects.
  • remoteLabel(remote) formats user@host:port:path, omitting empty user and port (for example root@box:22:/srv/x).
  • normalizeRemote(input) trims host and path, returns null if either is empty, drops empty user/port fields, and returns a fresh ProjectRemote. The add-remote-project dialog validates through this before persisting.

So the persisted project is the source of truth for "remote or local"; every downstream operation receives a RemoteHost derived from that record rather than re-parsing user input.

Remote terminals: src/core/remote-pty.ts

Remote terminals use a local node-pty running ssh -tt <host> tmux new-session …. -tt forces the remote side to allocate a PTY, and tmux multiplexes it so remote terminals survive app restarts the same way local tmux-backed sessions do.

Spawn argv construction

remoteTmuxSpawnArgv(remote, sessionName, shell, remoteCwd?, launch?):

  1. Calls connectionArgs(remote) and splits it into options plus the trailing target (base.slice(0, -1) and base[base.length - 1]).
  2. Prepends -tt so the remote PTY is allocated.
  3. Builds cwdArg = " -c <shq(remoteCwd)>" when a remote cwd is given.
  4. Builds paneCommand as shq(shell) -lc shq(launch) when a launch command exists, otherwise just shq(shell).
  5. Emits the tmux command as a single argv element: tmux new-session -A -D -s <sessionName><cwdArg> -- <paneCommand>.

The -A -D flags mean attach-or-create and detach other clients, and the whole tmux invocation is one element precisely because ssh joins argv into one remote command string.

Session helpers

Function Mode Behavior
remoteTmuxHasSession async tmux has-session -t <name>; true when code === 0.
remoteTmuxHasSessionSync sync spawnSync status check for the sync create() path; false on throw.
remoteTmuxKillSession async tmux kill-session -t <name>; idempotent.
remoteTmuxCapture async tmux capture-pane -p -S -200 -t <name>; null when the session is gone. Used by the Telegram bot's peek/attach.
remoteTmuxCaptureSync sync Sync capture with a 5000 ms timeout, for the bot's sync peek/attach loop.
remoteTmuxKillSessionSync sync Sync kill for the sync destroy path; swallows errors for idempotence.

The sync variants exist because create/destroy and the bot's peek loop run synchronously; the async variants serve normal request paths.

Wiring in pty-manager.ts

pty-manager.ts owns terminal sessions and documents the central invariant: each session runs inside a persistent tmux session and the node id is the tmux session key — keep it stable. create() probes tmux has-session before spawning so the PtyCreateResult can carry a fresh flag:

  • fresh: false — warm reattach, tmux redraws the existing session.
  • fresh: true — cold start (including the no-tmux fallback, which runs a plain shell with no cross-restart continuity).

Inputs are validated by TERMINAL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/ before being used as a session key. isMissingTmuxSessionError matches tmux stderr such as can't find session: or error connecting to … (No such file …) so teardown paths can treat an already-dead session as benign. The manager imports remoteTmuxSpawnArgv, remoteTmuxHasSessionSync, remoteTmuxKillSessionSync, and remoteTmuxCaptureSync for the remote branch, and the shared ptyDataChannel / ptyExitChannel event surface, so remote sessions present to the rest of the app through the same channels as local ones. ScrollbackStore is imported alongside for the local scrollback side of cold replay.

sequenceDiagram
    autonumber
    participant PM as pty-manager.create
    participant RP as core/remote-pty
    participant SH as system ssh
    participant H as Remote tmux host
    PM->>RP: remoteTmuxHasSessionSync(remote, sessionName)
    RP->>SH: spawnSync ssh + connectionArgs + tmux has-session
    SH->>H: connect
    H-->>RP: exit status
    RP-->>PM: exists? fresh = !exists
    PM->>RP: remoteTmuxSpawnArgv(remote, name, shell, cwd, launch)
    RP-->>PM: ['-tt', ...opts, target, tmuxCmd]
    PM->>SH: local node-pty runs ssh -tt
    SH->>H: tmux new-session -A -D -s name
    Note over H: tmux owns the remote PTY; survives app restart
Loading

Key nodes: the pre-spawn probe is what produces the fresh flag; remoteTmuxSpawnArgv re-splits connectionArgs so -tt lands before the target rather than at the end; the tmux command is one argv element because ssh joins argv into a single remote shell command.

Remote files: src/core/remote-file.ts

Local side never runs a shell; the remote shell executes a command this module builds with every dynamic value single-quoted.

  • remoteFileReadCmd(path) → cat <quoted path>.
  • remoteSh(remote, cmd, opts?) is a thin wrapper over runSshRaw for pre-quoted compound commands. The doc is explicit about why there is no sh -c wrapper: ssh joins argv into one string, so sh -c <script> <arg> would make the remote run <script> with <arg> as $0 instead of passing the intended argument. (The file header loosely says "the remote sh -c runs a command we build ourselves"; the implemented contract is the single-element form documented on remoteSh.)
  • remoteFileRead(remote, path, opts?) → { ok: true, content } or { ok: false, error: stderr.trim() || "ssh exited <code>" }.
  • remoteListDirCmd(path) emits distinguishable stderr markers: MISSING + exit 1 when ! -e, NOTDIR + exit 2 when ! -d, otherwise find <p> -maxdepth 1 -mindepth 1 -printf '%y\t%f\n'. This relies on GNU find (Debian/Ubuntu hosts).
  • parseRemoteDirListing(stdout, path) parses the %y\t%f pairs, maps d → dir and anything else → file, joins with posix.join, drops entries that start with . or are in REMOTE_SKIP = { node_modules, .git }, and sorts directories before files then by localeCompare. This mirrors listProjectDir semantics.
  • remoteListDir maps MISSING → { code: 'MISSING', message: 'folder not found' }, NOTDIR → { code: 'IO', message: 'path is not a folder' }, and anything else → IO with the trimmed stderr or exit code. Listing never walks outside the requested path.
  • remoteFileWrite(remote, path, content, opts?) builds mkdir -p <quoted dirname> && cat > <quoted path> and pipes content on stdin via runSshWithInput, so file content is never shell-quoted and any text is safe.

Remote git: src/core/remote-git.ts

This mirrors the local git-service surface but runs git -C <remotePath> through runSsh. Every op takes an optional SshOptions, so callers that thread sshControlPath(userDataPath, remote) from main get ControlMaster multiplexing across repeated calls for one project.

remoteGitArgs(remotePath, args) → ['git', '-C', remotePath, '-c', 'color.ui=false', ...args]. Because git -C changes directory itself, git commands need no remote shell string; runSsh argv serialization with shq is enough.

Operation Remote argv Notes
remoteGitStatus status --porcelain Raw GitResult via toGitResult.
remoteGitStatusChanges via status Parses with the shared parseGitStatus; returns [] on non-zero code.
remoteGitCommit commit -m <message> Message quoted by remoteCommand.
remoteRepoRoot rev-parse --show-toplevel null when the path is not in a remote repo.
remoteCurrentBranch branch --show-current '' on failure.
remoteListBranches branch --format=%(HEAD)%09%(refname:short) Tab split; line[0] === '*' marks current.
remoteSyncState status -sb --porcelain=1 First line parsed by the shared parseSyncState; failure defaults to { upstream: null, ahead: 0, behind: 0 }.
remoteRemoteUrl remote get-url <name> Default name origin; null on failure.
remoteStageChanges add -A -- <paths>
remoteUnstageChanges restore --staged -- <paths>
remoteDiscardChanges checkout -- <paths> Discards uncommitted working-tree edits.
remoteCreateBranch checkout -b <name> Rejects invalid refs locally with code 128 before any ssh call.
remoteCheckoutBranch checkout <name> Same local validation.
remoteRecentCommits log -<limit> --pretty=format:%h%x09%an%x09%ad%x09%s --date=short limit defaults to 20; parsed by parseRemoteCommits.
remotePush (evidence truncated at the start of this function)

Behavior sharing is deliberate: parseGitStatus, parseSyncState, and isValidGitRefName come from core/git-service, so remote and local results stay structurally identical. parseRemoteCommits is kept pure (tab-separated hash|author|date|subject, skipping lines without a hash) so it is unit-testable without ssh.

Call chains

  1. Remote terminal attach — pty-manager.create → remoteTmuxHasSessionSync (sync probe; decides fresh) → remoteTmuxSpawnArgv → local node-pty spawns ssh -tt → remote tmux new-session -A -D. Data/exit flow back through ptyDataChannel / ptyExitChannel.
  2. Remote file read — remoteFileRead → remoteFileReadCmd (cat with shq) → remoteSh → runSshRaw → ssh → remote shell.
  3. Remote file write — remoteFileWrite → mkdir -p <dir> && cat > <path> → runSshWithInput → child.stdin.end(content).
  4. Remote directory list — remoteListDir → remoteListDirCmd → remoteSh → marker-aware error mapping or parseRemoteDirListing.
  5. Remote git op — e.g. remoteStageChanges → remoteGitArgs → runSsh → remoteCommand/shq → remote shell runs git -C … add -A -- ….

Key state

  • Persisted project record — ProjectRemote { host, path, user?, port? } on the project; cwd is null for remote projects. Normalized once at creation time by normalizeRemote.
  • ControlMaster socket — one per host/user/port under <userDataPath>/ssh/ctl-<safe>, created lazily by ssh on first use and kept for ControlPersist=600. Terminals intentionally do not share it.
  • Remote tmux session — named by the stable node id; its existence is the durable state that makes reattach possible. fresh in the create result records which case occurred.
  • Local scrollback — captured through ScrollbackStore alongside the session, complementing tmux's redraw on warm reattach.

Boundaries and invariants

  • Quoting is mandatory and layered. Local argv is never shell-interpreted; the remote shell interprets the joined string. Every dynamic value must go through shq/remoteCommand, and custom compound commands must use runSshRaw/remoteSh.
  • BatchMode means non-interactive auth. No password or passphrase prompt is possible; keys/agent must already work. StrictHostKeyChecking=accept-new accepts unknown hosts on first contact but still rejects changed keys.
  • Executors never reject. Spawn failures resolve as { code: 1, stdout: '', stderr: message }, so callers always branch on code/ok.
  • Listing constraints. Remote listing is one level deep, skips dotfiles plus node_modules/.git, and depends on GNU find -printf. Missing vs. non-directory is communicated by sentinel stderr markers, not by fuzzy output matching.
  • Terminal ids are validated against the pattern before becoming tmux session keys; sameRemoteHost normalizes root/22 defaults so equivalent destinations compare equal.
  • Branch names are validated locally (isValidGitRefName) before any ssh round trip, returning git-style code 128 without network cost.
  • No sh -c wrapper for compound remote commands. The command must be the single argv element ssh forwards to the remote shell.

Extension points

  • Add a new remote operation by composing runSsh (simple argv), remoteSh/runSshRaw (compound, self-quoted), or runSshWithInput (stdin payload), and thread SshOptions.controlPath through to inherit multiplexing.
  • Reuse the local parsers/validators (parseGitStatus, parseSyncState, isValidGitRefName, parseRemoteCommits) whenever mirroring a local service, so remote output stays byte-compatible with local expectations.
  • Add remote tmux verbs by following remote-pty.ts: an async variant plus, when a sync path needs it, a spawnSync variant that returns null/swallows errors when the session is gone.
  • Extend the remote project contract in src/shared/remote-project.ts (pure, renderer-safe); keep src/core/remote-project.ts a re-export only.
  • remotePush is the visible end of the remote git surface in this evidence; further ops should follow the same remoteGitArgs + runSsh + toGitResult shape.

Sources: src/core/ssh.ts, src/core/remote-project.ts, src/shared/remote-project.ts, src/core/remote-pty.ts, src/core/remote-file.ts, src/core/remote-git.ts, src/core/pty-manager.ts

termsprawl

App Shell & Platform Foundations

Canvas, Nodes & Renderer State

Terminals & Session Continuity

Persistence, Projects & Files

Agent Runtime & Tooling

Chat Nodes & Model Providers

Git & Source Control

Embedded Browser Nodes

Server Edition

Relay & Remote Access

Integrations & Secondary Surfaces

Settings, Updates & Maintenance

Clone this wiki locally