-
Notifications
You must be signed in to change notification settings - Fork 0
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 | 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.
Three shapes define the contract:
RemoteHost { host: string; user?: string; port?: number }SshResult { code: number; stdout: string; stderr: string }SshOptions { controlPath?: string }
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.
connectionArgs always emits:
-o BatchMode=yes-o StrictHostKeyChecking=accept-new- optional
-o ControlMaster=auto -o ControlPath=<path> -o ControlPersist=600whenSshOptions.controlPathis set -
-p <port>when present - the trailing
user@hosttarget
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.
| 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. |
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)mapsshqover 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"]
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.
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 whenproject.remote != null. The doc comment ties this tocwdbeing null for remote projects. -
remoteLabel(remote)formatsuser@host:port:path, omitting empty user and port (for exampleroot@box:22:/srv/x). -
normalizeRemote(input)trimshostandpath, returnsnullif either is empty, drops emptyuser/portfields, and returns a freshProjectRemote. 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 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.
remoteTmuxSpawnArgv(remote, sessionName, shell, remoteCwd?, launch?):
- Calls
connectionArgs(remote)and splits it into options plus the trailing target (base.slice(0, -1)andbase[base.length - 1]). - Prepends
-ttso the remote PTY is allocated. - Builds
cwdArg = " -c <shq(remoteCwd)>"when a remote cwd is given. - Builds
paneCommandasshq(shell) -lc shq(launch)when a launch command exists, otherwise justshq(shell). - 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.
| 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.
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
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.
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 overrunSshRawfor pre-quoted compound commands. The doc is explicit about why there is nosh -cwrapper: ssh joins argv into one string, sosh -c <script> <arg>would make the remote run<script>with<arg>as$0instead of passing the intended argument. (The file header loosely says "the remotesh -cruns a command we build ourselves"; the implemented contract is the single-element form documented onremoteSh.) -
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, otherwisefind <p> -maxdepth 1 -mindepth 1 -printf '%y\t%f\n'. This relies on GNUfind(Debian/Ubuntu hosts). -
parseRemoteDirListing(stdout, path)parses the%y\t%fpairs, mapsd→dirand anything else →file, joins withposix.join, drops entries that start with.or are inREMOTE_SKIP = { node_modules, .git }, and sorts directories before files then bylocaleCompare. This mirrorslistProjectDirsemantics. -
remoteListDirmapsMISSING→{ code: 'MISSING', message: 'folder not found' },NOTDIR→{ code: 'IO', message: 'path is not a folder' }, and anything else →IOwith the trimmed stderr or exit code. Listing never walks outside the requested path. -
remoteFileWrite(remote, path, content, opts?)buildsmkdir -p <quoted dirname> && cat > <quoted path>and pipescontenton stdin viarunSshWithInput, so file content is never shell-quoted and any text is safe.
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.
-
Remote terminal attach —
pty-manager.create→remoteTmuxHasSessionSync(sync probe; decidesfresh) →remoteTmuxSpawnArgv→ localnode-ptyspawnsssh -tt→ remotetmux new-session -A -D. Data/exit flow back throughptyDataChannel/ptyExitChannel. -
Remote file read —
remoteFileRead→remoteFileReadCmd(catwithshq) →remoteSh→runSshRaw→ssh→ remote shell. -
Remote file write —
remoteFileWrite→mkdir -p <dir> && cat > <path>→runSshWithInput→child.stdin.end(content). -
Remote directory list —
remoteListDir→remoteListDirCmd→remoteSh→ marker-aware error mapping orparseRemoteDirListing. -
Remote git op — e.g.
remoteStageChanges→remoteGitArgs→runSsh→remoteCommand/shq→ remote shell runsgit -C … add -A -- ….
-
Persisted project record —
ProjectRemote { host, path, user?, port? }on the project;cwdis null for remote projects. Normalized once at creation time bynormalizeRemote. -
ControlMaster socket — one per host/user/port under
<userDataPath>/ssh/ctl-<safe>, created lazily by ssh on first use and kept forControlPersist=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.
freshin the create result records which case occurred. -
Local scrollback — captured through
ScrollbackStorealongside the session, complementing tmux's redraw on warm reattach.
-
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 userunSshRaw/remoteSh. -
BatchMode means non-interactive auth. No password or passphrase prompt is possible; keys/agent must already work.
StrictHostKeyChecking=accept-newaccepts 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 oncode/ok. -
Listing constraints. Remote listing is one level deep, skips dotfiles plus
node_modules/.git, and depends on GNUfind -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;
sameRemoteHostnormalizesroot/22defaults so equivalent destinations compare equal. -
Branch names are validated locally (
isValidGitRefName) before any ssh round trip, returning git-stylecode 128without network cost. -
No
sh -cwrapper for compound remote commands. The command must be the single argv element ssh forwards to the remote shell.
- Add a new remote operation by composing
runSsh(simple argv),remoteSh/runSshRaw(compound, self-quoted), orrunSshWithInput(stdin payload), and threadSshOptions.controlPaththrough 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, aspawnSyncvariant that returnsnull/swallows errors when the session is gone. - Extend the remote project contract in
src/shared/remote-project.ts(pure, renderer-safe); keepsrc/core/remote-project.tsa re-export only. -
remotePushis the visible end of the remote git surface in this evidence; further ops should follow the sameremoteGitArgs+runSsh+toGitResultshape.
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
Generated from termsprawl at 0d4393be54c6200beedd91bb636e5296c30472c5.
App Shell & Platform Foundations
- Electron Main Process & Window Lifecycle
- Preload Bridge & IPC Contract
- Shared Domain Types and File/URL Helpers
- Renderer Bootstrap & App Composition
- Build Targets & TypeScript Configuration
Canvas, Nodes & Renderer State
- Infinite Canvas Surface & Viewport Interaction
- Workspace, Project & Tab State
- Node Links, Edges & Link Inspector
- Sticky, Group, Editor & Diff Nodes
- Keyboard Canvas Navigation & Cross-Panel Requests
- Theme, Accent & Visual Language
- Boot Overlay, Onboarding & Shared UI Kit
Terminals & Session Continuity
- PTY Lifecycle & Terminal Sessions
- tmux Session Naming & Reattach
- Scrollback Snapshots & Cold Replay
- Terminal Node Rendering (xterm.js)
- SSH Remote Projects, Terminals & Files
Persistence, Projects & Files
- Workspace Store & Project File Layout
- Project Scope, Deletion & Worktree Registry
- Workspace Bundle Export/Import
- File Service & File Tree UI
Agent Runtime & Tooling
- Agent Status Model & Hook Normalization
- Hook Server & CLI Hook Installers
- Agent Launch, CLI Probing & Managed Accounts
- Agent Tool Protocol & In-Process Server
- Agent Tool Client, CLI & MCP Entry
- Transcripts, Context Discovery & Context CLI
- Agent Canvas State & Status Badges
Chat Nodes & Model Providers
- Chat Runtime, Conversation & Cost
- Model Provider Adapters & Streaming
- Chat Tool Calling & Project Tools
- Chat Node UI
Git & Source Control
Embedded Browser Nodes
- Browser Manager & Guest Runtime
- CDP Facade & Browser Agent Server
- Browser Navigation Policy & Node UI
Server Edition
- Server Bootstrap & HTTP/WebSocket Entry
- RPC Dispatch, Handlers & Service Bridges
- Renderer Shim & Server Boundary
- Server Auth & Security Boundary
Relay & Remote Access
- Relay Hub & WebSocket Frame Routing
- Relay End-to-End Cryptography
- Relay Auth, Invites, Store & Admin API
- Relay Client, Pairing & Terminal Tunneling
- Relay Trust UI
Integrations & Secondary Surfaces
- Telegram Bot, Commands & Pairing
- A2A Peers: Protocol, Client & Server
- Node Link Engine, Registry & Scheduler
- Cloud Spaces, Snapshots & Sync
Settings, Updates & Maintenance