Skip to content

Transport Layer

Alan Wizemann edited this page Apr 20, 2026 · 7 revisions

Transport Layer

The ServerTransport protocol unifies local and SSH I/O. Services consume transport.readFile(path), transport.runProcess(...), transport.snapshotSQLite(path), etc., without caring whether the bytes come from disk or the wire. Two implementations live under scarf/scarf/scarf/Core/Transport/: LocalTransport and SSHTransport.

Protocol surface

ServerTransport.swift exposes:

Identity

  • contextID: ServerID — UUID; namespaces caches under ~/Library/Caches/scarf/snapshots/<id>/.
  • isRemote: Bool — true for SSHTransport.

File I/O

  • readFile(_ path) -> Data
  • writeFile(_ path, data:) — atomic via temp + swap; preserves 0600 mode for .env/auth.json/*-tokens.json.
  • fileExists(_ path) -> Bool
  • stat(_ path) -> FileStat? — size, mtime, isDirectory.
  • listDirectory(_ path) -> [String]
  • createDirectory(_ path) — idempotent, creates intermediates.
  • removeFile(_ path) — idempotent.

Processes

  • runProcess(executable, args, stdin, timeout) -> ProcessResult — blocking; captures stdout/stderr; SIGTERM on timeout.
  • makeProcess(executable, args) -> Process — pre-configured but not yet started; caller owns lifecycle (used by ACPClient).

SQLite snapshots

  • snapshotSQLite(remotePath) -> URL — local: returns the path unchanged. Remote: sqlite3 .backup on the remote, scp the result down, return a local URL into the snapshot cache.

Watching

  • watchPaths(_ paths) -> AsyncStream<WatchEvent> — yields .anyChanged on any change. Local: FSEvents (DispatchSourceFileSystemObject). Remote: 3-second mtime polling.

Errors

TransportErrors.swift defines TransportError:

Case Cause
hostUnreachable(host, stderr) DNS, connection refused, no route.
authenticationFailed(host, stderr) SSH key not loaded or rejected.
hostKeyMismatch(host, stderr) ~/.ssh/known_hosts mismatch.
commandFailed(exitCode, stderr) Remote command exited non-zero.
fileIO(path, underlying) Local FS error.
timeout(seconds, partialStdout) Hit timeout parameter.
other(message) Catch-all.

Stderr-pattern classification turns raw ssh errors into the right case so the UI can render actionable text.

LocalTransport

LocalTransport.swift — a thin wrapper around FileManager, Process, and DispatchSourceFileSystemObject.

  • Atomic writes: writes to <path>.scarf.tmp, sets 0600 if the filename suggests a secret, then replaceItemAt (existing) or moveItem (new).
  • Process timeout: polls every 100ms until deadline; terminate() if exceeded.
  • Watching: opens each path with O_EVTONLY, creates a dispatch source for .write/.extend/.rename, yields .anyChanged on event.
  • Snapshot: no-op — returns the path unchanged.

SSHTransport

SSHTransport.swift — every primitive becomes an ssh/scp/sftp invocation, multiplexed over a single ControlMaster connection.

ControlMaster pooling

Without ControlMaster, every remote call re-authenticates (500ms-2s each). With it, the first call sets up the master socket; subsequent calls reuse the same TCP+crypto session at ~5ms each.

The SSH option set is constructed by sshArgs(extra:):

-o ControlMaster=auto
-o ControlPath=~/Library/Caches/scarf/ssh/%C
-o ControlPersist=600          # keep alive 600s after last use
-o ServerAliveInterval=30      # keepalive every 30s
-o ServerAliveCountMax=3       # disconnect after 3 missed
-o ConnectTimeout=10
-o StrictHostKeyChecking=accept-new
-o LogLevel=QUIET              # binary-clean stdin/stdout for JSON-RPC
-o BatchMode=yes               # ssh-agent only; never prompt

%C hashes (host, user, port) — multiple Scarf windows for the same host share one socket. closeControlMaster() issues ssh -O exit for clean shutdown.

Path handling

Two helpers prevent shell-expansion breakage:

  • shellQuote(_:) — wraps unsafe strings in single quotes, escaping embedded singles as '\''. Safe characters (alphanumerics + @%+=:,./-_) pass through unquoted.
  • remotePathArg(_:) — converts ~/... to $HOME/... (because shells don't expand ~ inside quotes) and double-quotes so $HOME expands but spaces don't break.

File I/O over SSH

  • readFile: ssh host -- sh -c 'cat <path>'; classifies "No such file" into typed fileIO.
  • writeFile: scp to <path>.scarf.tmp, then remote mv — atomic; cleans the orphan on failure.
  • stat: tries GNU stat -c "%s %Y %F", falls back to BSD stat -f "%z %m %HT".
  • listDirectory: ls -A <path>. createDirectory: mkdir -p. removeFile: rm -f.

Process execution

  • runProcess: wraps <exe> <args> in sh -c so paths can use $HOME. Inherits SSH_AUTH_SOCK from the user's GUI environment so 1Password / Secretive agents work.
  • makeProcess: returns /usr/bin/ssh -T <opts> host -- sh -c '<exe> <args>'. The -T disables PTY allocation so stdin/stdout stay binary-clean for JSON-RPC.

SQLite snapshot

The trickiest operation. The remote runs:

sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" && \
sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;"

.backup is WAL-safe — it captures a consistent snapshot without blocking writers. The PRAGMA journal_mode=DELETE strips WAL mode so the snapshot is self-contained (no -wal/-shm sidecars). scp pulls it to ~/Library/Caches/scarf/snapshots/<id>/state.db. The remote temp is removed.

Remote watching

3-second polling: the remote runs a one-liner concatenating mtimes for the watched paths, hashed into a signature. When the signature changes, the stream yields .anyChanged. Transient connection drops are tolerated.

Required tools on the remote

  • sqlite3 for the snapshot operation.
  • pgrep for the Dashboard's "is Hermes running" check.
  • ~/.hermes/ readable by the SSH user.

See Servers & Remote for setup and troubleshooting.


Last updated: 2026-04-20 — Scarf v2.0.1

Clone this wiki locally