iOS port M0b: extract Transport + ServerContext to ScarfCore

Second of four M0 sub-PRs. Moves the remaining cross-cutting
infrastructure — the ServerTransport protocol and its two implementations
(LocalTransport, SSHTransport), plus ServerContext and its helpers —
into ScarfCore so both Mac and (future) iOS targets share one codebase.

Files moved (5):
  - scarf/Core/Transport/ServerTransport.swift (+ FileStat, ProcessResult, WatchEvent)
  - scarf/Core/Transport/LocalTransport.swift
  - scarf/Core/Transport/SSHTransport.swift
  - scarf/Core/Transport/TransportErrors.swift
  - scarf/Core/Models/ServerContext.swift (+ SSHConfig, ServerKind, ServerID, UserHomeCache)

Split out of ServerContext.swift into a new Mac-target sibling file
scarf/Core/Models/ServerContext+Mac.swift:
  - runHermes(_:timeout:stdin:) — depends on HermesFileService
  - openInLocalEditor(_:) — depends on AppKit.NSWorkspace
These methods can't live in ScarfCore itself because ScarfCore must not
depend on main-target services or AppKit. iOS will provide a sibling
ServerContext+iOS.swift in M2+.

Removed: scarf/Core/Models/HermesPaths+Deprecated.swift.
  Zero callers in-tree; its only justification was that ServerContext
  used to be in the Mac target. With ServerContext in ScarfCore now,
  the deprecated forwarders are both unreachable AND dead code.

Breaking the ScarfCore → main-target circular dep in SSHTransport:
  The old SSHTransport.sshSubprocessEnvironment() called
  HermesFileService.enrichedEnvironment() to harvest SSH_AUTH_SOCK from
  the user's login shell. Replaced with a local #if os(macOS) helper
  SSHTransport.macLoginShellSSHAgent() that probes /bin/zsh for only
  the two SSH agent vars (no PATH/credentials — that's still in
  HermesFileService for ACP subprocess use). Behavior-identical on
  macOS; no-op on iOS/Linux.

Platform guards added in ScarfCore (runtime targets still macOS/iOS):
  - `#if canImport(os)` around os.Logger (definition + every call site,
    except the large Darwin-dependent ensureControlDir block).
  - `#if canImport(Darwin)` around LocalTransport.watchPaths (FSEvents)
    and SSHTransport.ensureControlDir (Darwin.stat/lstat). Linux gets
    a no-op empty stream and a best-effort FileManager.createDirectory
    fallback — neither is exercised at runtime on Linux, only compiled.
  - `#if canImport(SwiftUI)` around ServerContext's EnvironmentKey.
  - `#if canImport(AppKit)` inside the new ServerContext+Mac.swift
    extension.

Bug fixed: M0a's sed transform accidentally added `public` to protocol
requirements in ServerTransport.swift, e.g. `public nonisolated var
contextID: ServerID { get }`. Swift forbids access modifiers on
protocol requirements — stripped.

54 additional consumer files in the Mac target gained `import ScarfCore`.

Test coverage: 18 new tests in ScarfCoreTests/M0bTransportTests.swift.
Runs on Linux via
  docker run --rm -v $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test
Total suite: 34 / 34 passing (M0a's 16 + M0b's 18).

Updated scarf/docs/IOS_PORT_PLAN.md progress log with the shipped M0b
state and the Platform-guard patterns future phases should reuse.

https://claude.ai/code/session_019yMRP6mwZWfzVrPTqevx2y
This commit is contained in:
Claude
2026-04-22 22:06:36 +00:00
parent f6f31cabe4
commit 0fd2ceb9fc
68 changed files with 645 additions and 188 deletions
@@ -0,0 +1,262 @@
import Foundation
#if canImport(SwiftUI)
import SwiftUI
#endif
/// Stable identifier for a server entry in the user's registry. Backed by
/// `UUID` so it round-trips through `servers.json` and SwiftUI window-state
/// restoration without collisions.
public typealias ServerID = UUID
/// Connection parameters for a remote Hermes installation reached over SSH.
/// All fields are optional except `host` unset values defer to the user's
/// `~/.ssh/config` and the OpenSSH defaults.
public struct SSHConfig: Sendable, Hashable, Codable {
/// Hostname or `~/.ssh/config` alias.
public var host: String
/// Remote username. `nil` defer to `~/.ssh/config` or the local user.
public var user: String?
/// TCP port. `nil` 22 (or whatever `~/.ssh/config` says).
public var port: Int?
/// Absolute path to a private key. `nil` defer to ssh-agent /
/// `~/.ssh/config` identity files.
public var identityFile: String?
/// Override for the remote `$HOME/.hermes` directory. `nil` uses
/// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the
/// remote side).
public var remoteHome: String?
/// Resolved remote path to the `hermes` binary. Populated by
/// `SSHTransport` after the first `command -v hermes` probe; cached here
/// so subsequent calls skip the round trip.
public var hermesBinaryHint: String?
public init(
host: String,
user: String? = nil,
port: Int? = nil,
identityFile: String? = nil,
remoteHome: String? = nil,
hermesBinaryHint: String? = nil
) {
self.host = host
self.user = user
self.port = port
self.identityFile = identityFile
self.remoteHome = remoteHome
self.hermesBinaryHint = hermesBinaryHint
}
}
/// Distinguishes a local installation (the user's own `~/.hermes`) from a
/// remote one reached over SSH. Service behavior is identical in shape but
/// dispatches to different I/O primitives in Phase 2.
public enum ServerKind: Sendable, Hashable, Codable {
case local
case ssh(SSHConfig)
}
/// The per-server value that flows through `.environment` and gets handed to
/// every service and ViewModel. One `ServerContext` corresponds to one
/// Hermes installation; multi-window scenes construct one per window.
///
/// **Why every member is `nonisolated`.** Sibling extension methods in the
/// Mac app target (`ServerContext+Mac.swift`) touch `AppKit`
/// (`NSWorkspace.shared.open` in `openInLocalEditor`), which under Swift 6's
/// default-isolation rules pulls the whole struct to `@MainActor`.
/// `ServerContext` is a plain `Sendable` value accessing `.local`, `.paths`,
/// `.isRemote`, or `makeTransport()` from a background actor must not trap
/// the caller into hopping MainActor. `nonisolated` on each member keeps
/// them callable from any context.
public struct ServerContext: Sendable, Hashable, Identifiable {
public let id: ServerID
public var displayName: String
public var kind: ServerKind
public init(
id: ServerID,
displayName: String,
kind: ServerKind
) {
self.id = id
self.displayName = displayName
self.kind = kind
}
/// Path layout for this server. Cheap all path components are computed
/// on demand from `home`, no I/O.
public nonisolated var paths: HermesPathSet {
switch kind {
case .local:
return HermesPathSet(
home: HermesPathSet.defaultLocalHome,
isRemote: false,
binaryHint: nil
)
case .ssh(let config):
return HermesPathSet(
home: config.remoteHome ?? HermesPathSet.defaultRemoteHome,
isRemote: true,
binaryHint: config.hermesBinaryHint
)
}
}
public nonisolated var isRemote: Bool {
if case .ssh = kind { return true }
return false
}
/// Construct the `ServerTransport` for this context. Local contexts get
/// a `LocalTransport`; SSH contexts get an `SSHTransport` configured
/// from `SSHConfig`. Each call returns a fresh value transports are
/// cheap and stateless beyond disk caches.
public nonisolated func makeTransport() -> any ServerTransport {
switch kind {
case .local:
return LocalTransport(contextID: id)
case .ssh(let config):
return SSHTransport(contextID: id, config: config, displayName: displayName)
}
}
// MARK: - Well-known singletons
/// Stable UUID for the built-in "this machine" entry. Hard-coded so the
/// local context has the same identity across launches, and so persisted
/// window-state restorations that reference it continue to resolve even
/// if `servers.json` hasn't been touched yet.
nonisolated private static let localID = ServerID(uuidString: "00000000-0000-0000-0000-000000000001")!
/// The default "this machine" context. Used everywhere in Phase 0/1 and
/// remains the fallback when no remote server is selected.
public nonisolated static let local = ServerContext(
id: localID,
displayName: "Local",
kind: .local
)
}
// MARK: - Remote user-home resolution
/// Process-wide cache of each server's resolved user `$HOME`. Probed once per
/// `ServerID` via the transport, then memoized for the app's lifetime home
/// directories don't change under us, and the probe is a ~5ms SSH round-trip
/// with ControlMaster. Used by anything that needs to hand a working
/// directory to the ACP agent or the Hermes CLI on the correct host.
private actor UserHomeCache {
static let shared = UserHomeCache()
private var cache: [ServerID: String] = [:]
func resolve(for context: ServerContext) async -> String {
if let cached = cache[context.id] { return cached }
let resolved = await probe(context: context)
cache[context.id] = resolved
return resolved
}
func invalidate(contextID: ServerID) {
cache.removeValue(forKey: contextID)
}
private func probe(context: ServerContext) async -> String {
if !context.isRemote { return NSHomeDirectory() }
let transport = context.makeTransport()
let result = try? transport.runProcess(
executable: "/bin/sh",
args: ["-c", "echo $HOME"],
stdin: nil,
timeout: 10
)
let out = result?.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
// Fall back to `~` (unexpanded) so ACP at least gets a plausible cwd
// rather than a local Mac path. The remote side will expand it if
// passed through a shell; if not, failures are surfaced by ACP itself.
return out.isEmpty ? "~" : out
}
}
extension ServerContext {
/// Resolved absolute path to the user's home directory on the target host.
/// Local: `NSHomeDirectory()`. Remote: probed `$HOME` over SSH, cached.
/// Use this not `NSHomeDirectory()` whenever you're passing a `cwd`
/// or user path to a process that runs on the target host.
public func resolvedUserHome() async -> String {
await UserHomeCache.shared.resolve(for: self)
}
/// Called when a server is removed from the registry, so the process-wide
/// caches keyed by `ServerID` don't hold stale entries forever.
public static func invalidateCaches(for contextID: ServerID) async {
await UserHomeCache.shared.invalidate(contextID: contextID)
}
}
// MARK: - Convenience file I/O via the right transport
/// Centralized file I/O entry points for VMs that don't own a service. Every
/// call goes through the context's transport, so reads/writes hit the local
/// disk for `.local` and ssh/scp for `.ssh` automatically.
///
/// **Always** prefer `context.readText(...)` over `String(contentsOfFile: ...)`
/// when the path comes from `context.paths`. The Foundation file APIs are
/// LOCAL ONLY using them with a remote path silently returns nil because
/// the remote path doesn't exist on this Mac.
extension ServerContext {
/// Read a UTF-8 text file. `nil` on any error (missing, transport down,
/// invalid encoding).
public nonisolated func readText(_ path: String) -> String? {
guard let data = try? makeTransport().readFile(path) else { return nil }
return String(data: data, encoding: .utf8)
}
/// Read raw bytes. `nil` on any error.
public nonisolated func readData(_ path: String) -> Data? {
try? makeTransport().readFile(path)
}
/// Atomic write. Returns `true` on success, `false` on any error
/// (caller is expected to surface failures via UI when relevant).
@discardableResult
public nonisolated func writeText(_ path: String, content: String) -> Bool {
guard let data = content.data(using: .utf8) else { return false }
do {
try makeTransport().writeFile(path, data: data)
return true
} catch {
return false
}
}
/// Existence check. Local: `FileManager`. Remote: `ssh test -e`.
public nonisolated func fileExists(_ path: String) -> Bool {
makeTransport().fileExists(path)
}
/// File modification timestamp, or `nil` if the file doesn't exist.
public nonisolated func modificationDate(_ path: String) -> Date? {
makeTransport().stat(path)?.mtime
}
}
// MARK: - SwiftUI environment plumbing
/// `ServerContext` is a value type, so SwiftUI's `.environment(_:)` (which
/// requires an `@Observable` class) doesn't accept it directly. We expose it
/// through a custom `EnvironmentKey` views read it with
/// `@Environment(\.serverContext) private var serverContext`.
///
/// Guarded on `canImport(SwiftUI)` so ScarfCore still compiles on Linux
/// (swift-corelibs-foundation has no SwiftUI). Apple platforms the real
/// runtime targets compile the SwiftUI plumbing unchanged.
#if canImport(SwiftUI)
private struct ServerContextEnvironmentKey: EnvironmentKey {
static let defaultValue: ServerContext = .local
}
extension EnvironmentValues {
public var serverContext: ServerContext {
get { self[ServerContextEnvironmentKey.self] }
set { self[ServerContextEnvironmentKey.self] = newValue }
}
}
#endif
@@ -0,0 +1,215 @@
import Foundation
#if canImport(os)
import os
#endif
/// `ServerTransport` over the local filesystem. Thin wrapper around
/// `FileManager`, `Process`, and `DispatchSourceFileSystemObject` the APIs
/// services were already using before Phase 2.
///
/// **Platform note.** All Hermes code paths that actually construct a
/// `LocalTransport` run on macOS (iOS uses `SSHTransport` exclusively). The
/// `#if canImport(Darwin)` guards below exist only so ScarfCore still
/// compiles on Linux for `swift test` CI on Linux, file-watching is a
/// no-op stream and the subprocess spawn still works via Foundation's
/// `Process`.
public struct LocalTransport: ServerTransport {
#if canImport(os)
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "LocalTransport")
#endif
public let contextID: ServerID
public let isRemote: Bool = false
public nonisolated init(contextID: ServerID = ServerContext.local.id) {
self.contextID = contextID
}
// MARK: - Files
public func readFile(_ path: String) throws -> Data {
do {
return try Data(contentsOf: URL(fileURLWithPath: path))
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
public func writeFile(_ path: String, data: Data) throws {
let tmp = path + ".scarf.tmp"
do {
try data.write(to: URL(fileURLWithPath: tmp))
// Preserve `0600` for dotfiles holding secrets (.env, .auth, ...).
// The existing files already use 0600 via HermesEnvService; we
// mirror that here so a brand-new file created via this write
// also starts with safe permissions.
if Self.shouldEnforcePrivateMode(for: path) {
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: tmp)
}
// Atomic swap onto the final path.
let destURL = URL(fileURLWithPath: path)
let tmpURL = URL(fileURLWithPath: tmp)
if FileManager.default.fileExists(atPath: path) {
_ = try FileManager.default.replaceItemAt(destURL, withItemAt: tmpURL)
} else {
// Ensure parent exists.
let parent = (path as NSString).deletingLastPathComponent
if !parent.isEmpty, !FileManager.default.fileExists(atPath: parent) {
try FileManager.default.createDirectory(atPath: parent, withIntermediateDirectories: true)
}
try FileManager.default.moveItem(at: tmpURL, to: destURL)
}
} catch {
try? FileManager.default.removeItem(atPath: tmp)
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
public func fileExists(_ path: String) -> Bool {
FileManager.default.fileExists(atPath: path)
}
public func stat(_ path: String) -> FileStat? {
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else {
return nil
}
let size = (attrs[.size] as? Int64) ?? Int64((attrs[.size] as? Int) ?? 0)
let mtime = (attrs[.modificationDate] as? Date) ?? Date(timeIntervalSince1970: 0)
let isDir = (attrs[.type] as? FileAttributeType) == .typeDirectory
return FileStat(size: size, mtime: mtime, isDirectory: isDir)
}
public func listDirectory(_ path: String) throws -> [String] {
do {
return try FileManager.default.contentsOfDirectory(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
public func createDirectory(_ path: String) throws {
do {
try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
public func removeFile(_ path: String) throws {
guard FileManager.default.fileExists(atPath: path) else { return }
do {
try FileManager.default.removeItem(atPath: path)
} catch {
throw TransportError.fileIO(path: path, underlying: error.localizedDescription)
}
}
// MARK: - Processes
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
// Timeout handling: poll every 100ms up to timeout, kill on overrun.
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
public func makeProcess(executable: String, args: [String]) -> Process {
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
return proc
}
// MARK: - SQLite
public func snapshotSQLite(remotePath: String) throws -> URL {
// Local case: no copy needed. Services open the path directly.
URL(fileURLWithPath: remotePath)
}
// MARK: - Watching
#if canImport(Darwin)
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in
// Build the source list immutably, then hand a value-typed copy
// to onTermination. Swift 6's concurrent-capture rule rejects a
// `var sources` shared between the outer builder and the inner
// termination closure.
let sources: [DispatchSourceFileSystemObject] = paths.compactMap { path in
let fd = Darwin.open(path, O_EVTONLY)
guard fd >= 0 else { return nil }
let src = DispatchSource.makeFileSystemObjectSource(
fileDescriptor: fd,
eventMask: [.write, .extend, .rename],
queue: .global()
)
src.setEventHandler { continuation.yield(.anyChanged) }
src.setCancelHandler { Darwin.close(fd) }
src.resume()
return src
}
continuation.onTermination = { _ in
for s in sources { s.cancel() }
}
}
}
#else
/// Linux stub: no FSEvents, no inotify wiring for now. Returns an empty
/// stream so callers that `for await _ in transport.watchPaths(...)`
/// simply never tick. Real Linux deployment would switch this to an
/// inotify implementation, but Linux is a CI-only target for us, not a
/// runtime target the stub suffices.
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
AsyncStream { continuation in
continuation.finish()
}
}
#endif
// MARK: - Helpers
/// Heuristic: files that conventionally hold secrets should be created
/// with restrictive permissions so a future `scp` or editor doesn't end
/// up exposing them.
private static func shouldEnforcePrivateMode(for path: String) -> Bool {
let name = (path as NSString).lastPathComponent
return name == ".env" || name == "auth.json" || name.hasSuffix("-tokens.json")
}
}
@@ -0,0 +1,660 @@
import Foundation
#if canImport(os)
import os
#endif
/// `ServerTransport` that reaches a remote Hermes installation through the
/// system `ssh`, `scp`, and `sftp` binaries.
///
/// Why system ssh (not a native library): the user's `~/.ssh/config`,
/// ssh-agent, 1Password/Secretive agents, ProxyJump, and ControlMaster
/// multiplexing all work for free. OpenSSH also owns crypto a smaller
/// audit surface than dragging libssh2 along.
///
/// **ControlMaster matters.** Without it, every remote primitive (stat, cat,
/// cp) authenticates from scratch 500ms-2s per call. With ControlMaster
/// `auto` + `ControlPersist 600`, the first call authenticates, subsequent
/// calls reuse the same TCP/crypto session at ~5ms each. We point the
/// control socket at `~/Library/Caches/scarf/ssh/%C` so multiple Scarf
/// windows pointed at the same host share one session cleanly.
public struct SSHTransport: ServerTransport {
#if canImport(os)
nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport")
#endif
public let contextID: ServerID
public let isRemote: Bool = true
public let config: SSHConfig
public let displayName: String
public nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) {
self.contextID = contextID
self.config = config
self.displayName = displayName
}
// MARK: - ssh/scp binary discovery
nonisolated private var sshBinary: String { "/usr/bin/ssh" }
nonisolated private var scpBinary: String { "/usr/bin/scp" }
/// The fully-qualified `user@host` spec (or just `host` if no user set).
nonisolated private var hostSpec: String {
if let user = config.user, !user.isEmpty { return "\(user)@\(config.host)" }
return config.host
}
/// Absolute path to this server's ControlMaster socket directory. One
/// socket per server, lives under the app's Caches so macOS can sweep it.
nonisolated private var controlDir: String { Self.controlDirPath() }
/// Per-server snapshot cache directory (for SQLite `.backup` drops).
nonisolated private var snapshotDir: String { Self.snapshotDirPath(for: contextID) }
/// Shared control-master socket directory (one dir, sockets within it are
/// per-host via OpenSSH's `%C` token). Exposed as a static so
/// cleanup paths (`ServerRegistry.removeServer`, app-launch sweep) can
/// compute it without instantiating a transport.
///
/// Uses a short path under /tmp to stay within the 104-byte macOS
/// Unix domain socket limit. The Caches path
/// (~/Library/Caches/scarf/ssh/%C) can exceed this limit when the
/// username is long, causing ssh to exit 255.
public nonisolated static func controlDirPath() -> String {
return "/tmp/scarf-ssh-\(getuid())"
}
/// Snapshot cache directory for a given server. Stable per-ID so repeated
/// connections to the same server share the cache, and so cleanup can
/// find it from the ID alone.
public nonisolated static func snapshotDirPath(for contextID: ServerID) -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots/\(contextID.uuidString)"
}
/// Root of the snapshot cache (all servers). Used by the app-launch sweep
/// that prunes dirs whose UUID no longer appears in the registry.
public nonisolated static func snapshotRootPath() -> String {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path
?? NSHomeDirectory() + "/Library/Caches"
return base + "/scarf/snapshots"
}
/// Remove the snapshot directory for a server (no-op if absent). Called
/// on `removeServer` and on app-launch for orphaned dirs.
public static func pruneSnapshotCache(for contextID: ServerID) {
let dir = snapshotDirPath(for: contextID)
try? FileManager.default.removeItem(atPath: dir)
}
/// Walk the snapshot root and delete any directory whose UUID isn't in
/// `keep`. Called once at app launch so snapshots from servers the user
/// removed while the app was closed don't linger.
public static func sweepOrphanSnapshots(keeping keep: Set<ServerID>) {
let root = snapshotRootPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
for name in entries {
if let id = ServerID(uuidString: name), keep.contains(id) { continue }
try? FileManager.default.removeItem(atPath: root + "/" + name)
}
}
/// Remove ControlMaster socket files older than `staleAfter` seconds.
///
/// Socket basenames are %C hashes (not ServerIDs), so we can't keep "still
/// registered" sockets the way `sweepOrphanSnapshots` does. But
/// `ControlPersist` is 600s anything older than 30 minutes is guaranteed
/// to be a dead orphan from a crashed master, an unclean app exit, or a
/// server removed while another Scarf instance was holding the dir.
/// Wiping these on launch keeps `/tmp/scarf-ssh-<uid>/` from accumulating
/// indefinitely until reboot, while leaving any concurrent Scarf
/// instance's live sockets (always <600s old) untouched.
public static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) {
let root = controlDirPath()
guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return }
let cutoff = Date().addingTimeInterval(-staleAfter)
for name in entries {
let path = root + "/" + name
guard let attrs = try? FileManager.default.attributesOfItem(atPath: path),
let mtime = attrs[.modificationDate] as? Date
else { continue }
if mtime < cutoff {
try? FileManager.default.removeItem(atPath: path)
}
}
}
/// Ask OpenSSH to shut down this host's ControlMaster socket, so the TCP
/// session isn't held open after the user removes this server. If no
/// master is currently running, `ssh -O exit` exits non-zero we ignore
/// the exit code because the desired end state (no master) is reached
/// either way.
public func closeControlMaster() {
ensureControlDir()
let args = sshArgs(extra: ["-O", "exit", hostSpec])
_ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10)
}
/// Common ssh options used by every invocation. Keep every `-o` flag
/// here so we never drift between calls.
///
/// - `ControlMaster=auto` + `ControlPersist=600` gives us free connection
/// pooling for the bursty stat/cat/cp traffic the services produce.
/// - `StrictHostKeyChecking=accept-new` writes new hosts to
/// `known_hosts` silently the first time but blocks on key mismatch
/// the UX surfaced by `TransportError.hostKeyMismatch`.
/// - `ServerAliveInterval=30` makes dropped connections surface as a
/// process exit rather than a hang.
/// - `LogLevel=QUIET` suppresses the login banner so ACP's line-delimited
/// JSON stays binary-clean.
nonisolated private func sshArgs(extra: [String] = []) -> [String] {
var args: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "ServerAliveInterval=30",
"-o", "ServerAliveCountMax=3",
"-o", "ConnectTimeout=10",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes" // Never prompt for passphrases; ssh-agent only.
]
if let port = config.port { args += ["-p", String(port)] }
if let id = config.identityFile, !id.isEmpty {
args += ["-i", id]
}
args += extra
return args
}
/// Ensure the ControlMaster socket directory exists, is a real directory
/// (not a symlink), is owned by us, and has mode 0700. Called before every
/// ssh invocation.
///
/// Defensive against `/tmp` pre-creation: any local user can create
/// `/tmp/scarf-ssh-<uid>` before Scarf launches. Plain `mkdir -p` plus
/// `setAttributes` would silently accept a hostile dir (since the chmod
/// fails when we don't own it, and the Foundation API swallows that). So
/// we use POSIX `mkdir` (atomic, sets perms at create time, doesn't
/// follow symlinks) and `lstat` to verify ownership when the entry
/// already exists.
nonisolated private func ensureControlDir() {
#if canImport(Darwin)
let path = controlDir
let mkResult = path.withCString { mkdir($0, 0o700) }
if mkResult == 0 { return }
let mkErr = errno
if mkErr != EEXIST {
Self.logger.error("Failed to create ControlDir \(path, privacy: .public): errno=\(mkErr)")
return
}
var st = Darwin.stat()
let lstatResult = path.withCString { lstat($0, &st) }
guard lstatResult == 0 else {
Self.logger.error("Could not lstat existing ControlDir \(path, privacy: .public): errno=\(errno)")
return
}
guard (st.st_mode & S_IFMT) == S_IFDIR else {
Self.logger.error("ControlDir \(path, privacy: .public) exists but is not a directory (possibly a symlink) — refusing to use")
return
}
guard st.st_uid == getuid() else {
Self.logger.error("ControlDir \(path, privacy: .public) owned by uid \(st.st_uid), expected \(getuid()) — refusing to use")
return
}
if (st.st_mode & 0o777) != 0o700 {
Self.logger.warning("ControlDir \(path, privacy: .public) had mode \(String(st.st_mode & 0o777, radix: 8), privacy: .public), repairing to 700")
_ = path.withCString { chmod($0, 0o700) }
}
#else
// Linux (CI-only) stub: SSH isn't exercised at runtime on Linux, so
// we don't need a real ControlMaster setup. A best-effort mkdir is
// enough for any tests that poke at `controlDir`.
try? FileManager.default.createDirectory(atPath: controlDir, withIntermediateDirectories: true)
#endif
}
/// Shell-quote a single argument for remote execution. The remote shell
/// receives our argv joined with spaces, so anything containing
/// whitespace/metacharacters must be quoted to survive that flattening.
nonisolated private static func shellQuote(_ s: String) -> String {
if s.isEmpty { return "''" }
// Safe subset: alphanumerics + a few shell-inert characters.
let safe = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789@%+=:,./-_")
if s.unicodeScalars.allSatisfy({ safe.contains($0) }) { return s }
// Wrap in single quotes; close/reopen around any embedded single quote.
return "'" + s.replacingOccurrences(of: "'", with: "'\\''") + "'"
}
/// Format a path for inclusion in a remote `sh -c` command. **Critical**
/// for any path containing `~/`: bash/zsh do NOT expand `~` inside
/// quotes (single OR double), so a single-quoted `'~/.hermes/foo'` is
/// passed to commands as the literal seven-character string
/// `~/.hermes/foo` and lookups fail. We rewrite the leading `~/` to
/// `$HOME/` (which DOES expand inside double quotes) and emit the path
/// double-quoted so embedded spaces / metacharacters are still safe.
///
/// Why not single-quote: that would make `$HOME` literal too. We
/// specifically need partial-expansion semantics, which is what double
/// quotes give us.
nonisolated private static func remotePathArg(_ path: String) -> String {
var p = path
if p.hasPrefix("~/") {
p = "$HOME/" + p.dropFirst(2)
} else if p == "~" {
p = "$HOME"
}
let escaped = p
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}
/// Run a remote shell command. Wraps in `sh -c '<command>'` and uses
/// the standard ssh-after-host placement (no `--` separator that
/// would be sent to the remote shell as a literal first token, which
/// most shells reject as "command not found"). The `command` is
/// single-quoted via `shellQuote` so ssh's argv-join-by-space doesn't
/// split it across multiple shell tokens on the remote side.
@discardableResult
nonisolated private func runRemoteShell(_ command: String, timeout: TimeInterval? = 60) throws -> ProcessResult {
var args = sshArgs()
args.append(hostSpec)
args.append("sh")
args.append("-c")
args.append(Self.shellQuote(command))
return try runLocal(executable: sshBinary, args: args, stdin: nil, timeout: timeout)
}
// MARK: - Files
public func readFile(_ path: String) throws -> Data {
// `cat` is the simplest portable "give me file bytes" command; we
// don't need scp's progress machinery for typical config/memory
// files (<1 MB each).
let result = try runRemoteShell("cat \(Self.remotePathArg(path))")
if result.exitCode != 0 {
let errText = result.stderrString
// Missing file looks like exit 1 + "No such file" surface as a
// typed fileIO error so callers that treat missing == "empty"
// behave the same as they do locally.
if errText.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: errText)
}
return result.stdout
}
public func writeFile(_ path: String, data: Data) throws {
// Atomic pattern:
// 1. scp to `<path>.scarf.tmp` on the remote
// 2. ssh `mv <tmp> <path>` atomic on POSIX within the same FS
// Hermes never sees a partial write.
let tmp = path + ".scarf.tmp"
// scp from a local temp file (scp reads from disk, not stdin).
let localTmpURL = FileManager.default.temporaryDirectory.appendingPathComponent(
"scarf-scp-\(UUID().uuidString).tmp"
)
do {
try data.write(to: localTmpURL)
} catch {
throw TransportError.fileIO(path: path, underlying: "local temp write: \(error.localizedDescription)")
}
defer { try? FileManager.default.removeItem(at: localTmpURL) }
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append(localTmpURL.path)
scpArgs.append("\(hostSpec):\(tmp)")
let scpResult = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 60)
if scpResult.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: scpResult.exitCode, stderr: scpResult.stderrString)
}
// Now atomic mv on the remote. Note: scp/sftp DOES expand `~` (it
// goes through the SSH file transfer protocol, not a remote shell),
// so the upload landed at the resolved $HOME path. The mv is a
// shell command and needs the $HOME-rewritten path to find it.
let mvResult = try runRemoteShell("mv \(Self.remotePathArg(tmp)) \(Self.remotePathArg(path))")
if mvResult.exitCode != 0 {
// Best-effort cleanup of the orphan tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(tmp))")
throw TransportError.classifySSHFailure(host: config.host, exitCode: mvResult.exitCode, stderr: mvResult.stderrString)
}
}
public func fileExists(_ path: String) -> Bool {
guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else {
return false
}
return result.exitCode == 0
}
public func stat(_ path: String) -> FileStat? {
// macOS and Linux `stat` differ in flags. `stat -f` is macOS's BSD
// form; `stat -c` is GNU/Linux. We try the GNU form first (typical
// remote target) and fall back to BSD. The format strings use
// double quotes safe inside our outer single-quoted sh -c.
let linux = try? runRemoteShell(#"stat -c "%s %Y %F" \#(Self.remotePathArg(path))"#)
if let result = linux, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
let bsd = try? runRemoteShell(#"stat -f "%z %m %HT" \#(Self.remotePathArg(path))"#)
if let result = bsd, result.exitCode == 0 {
return Self.parseStatOutput(result.stdoutString)
}
return nil
}
private static func parseStatOutput(_ s: String) -> FileStat? {
// Expected: "<bytes> <unix-epoch-secs> <type>" where <type> is either
// a GNU word ("regular file", "directory") or a BSD word ("Regular
// File", "Directory"). Only the first word of <type> matters for
// isDirectory.
let parts = s.trimmingCharacters(in: .whitespacesAndNewlines).split(separator: " ", maxSplits: 2)
guard parts.count >= 2 else { return nil }
let size = Int64(parts[0]) ?? 0
let mtimeSecs = TimeInterval(parts[1]) ?? 0
let typeStr = parts.count == 3 ? parts[2].lowercased() : ""
let isDir = typeStr.contains("directory")
return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir)
}
public func listDirectory(_ path: String) throws -> [String] {
// `ls -A` lists all entries (incl. dotfiles) except `.`/`..`, one per
// line. Sort order matches local FileManager.contentsOfDirectory.
let result = try runRemoteShell("ls -A \(Self.remotePathArg(path))")
if result.exitCode != 0 {
if result.stderrString.contains("No such file") {
throw TransportError.fileIO(path: path, underlying: "No such file or directory")
}
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
return result.stdoutString
.split(separator: "\n", omittingEmptySubsequences: true)
.map(String.init)
}
public func createDirectory(_ path: String) throws {
let result = try runRemoteShell("mkdir -p \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
public func removeFile(_ path: String) throws {
let result = try runRemoteShell("rm -f \(Self.remotePathArg(path))")
if result.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: result.exitCode, stderr: result.stderrString)
}
}
// MARK: - Processes
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
// Wrap in `sh -c '<exe> <arg> <arg>'` with `~/`-rewritten paths so
// home-relative args expand on the remote. The executable might be
// `~/.local/bin/hermes` or just `hermes`; either survives.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout)
}
public func makeProcess(executable: String, args: [String]) -> Process {
ensureControlDir()
// `-T` disables pty allocation critical for binary-clean stdin/stdout
// (ACP JSON-RPC, log tail bytes). Same sh -c wrapping as runProcess
// so home-relative paths in `executable`/`args` actually expand.
let cmd = ([executable] + args).map { Self.remotePathArg($0) }.joined(separator: " ")
var sshArgv = sshArgs()
sshArgv.insert("-T", at: 0)
sshArgv.append(hostSpec)
sshArgv.append("sh")
sshArgv.append("-c")
sshArgv.append(Self.shellQuote(cmd))
let proc = Process()
proc.executableURL = URL(fileURLWithPath: sshBinary)
proc.arguments = sshArgv
proc.environment = Self.sshSubprocessEnvironment()
return proc
}
/// Environment for an ssh/scp subprocess: process env merged with
/// SSH_AUTH_SOCK / SSH_AGENT_PID harvested from the user's login shell.
/// Without this, GUI-launched Scarf can't reach 1Password / Secretive /
/// `ssh-add`'d keys that the user's terminal sees fine.
///
/// **macOS-only enrichment.** On iOS there's no user login shell SSH
/// agent is provided by the app itself (Citadel) in M4, not by a
/// `ssh-add`'d key loaded via `.zshrc`. On Linux CI there's no SSH
/// invocation actually happening, just compilation checks. Both cases
/// fall back to `ProcessInfo.processInfo.environment` verbatim.
nonisolated private static func sshSubprocessEnvironment() -> [String: String] {
var env = ProcessInfo.processInfo.environment
#if os(macOS)
let shellEnv = Self.macLoginShellSSHAgent()
for key in ["SSH_AUTH_SOCK", "SSH_AGENT_PID"] {
if env[key] == nil, let value = shellEnv[key], !value.isEmpty {
env[key] = value
}
}
#endif
return env
}
/// macOS-only: probe `/bin/zsh -l -c` for `SSH_AUTH_SOCK` and
/// `SSH_AGENT_PID`. GUI-launched apps don't inherit the user's shell
/// env, so without this, `ssh` spawned from Scarf can't reach the
/// ssh-agent and authentication fails with "Permission denied"
/// (exit 255) even though terminal ssh works fine.
///
/// Scoped down from the broader `HermesFileService.enrichedEnvironment()`
/// we only need two vars here, no PATH/credentials harvesting so
/// SSHTransport can live in `ScarfCore` without a main-target
/// dependency. Cached after first probe for the process lifetime.
#if os(macOS)
nonisolated private static let macLoginShellSSHAgentCache: [String: String] = {
let pipe = Pipe()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: "/bin/zsh")
proc.arguments = ["-l", "-c", #"printf '%s\0%s\0%s\0%s\0' "SSH_AUTH_SOCK" "$SSH_AUTH_SOCK" "SSH_AGENT_PID" "$SSH_AGENT_PID""#]
proc.standardOutput = pipe
proc.standardError = Pipe()
do {
try proc.run()
} catch {
return [:]
}
// Bounded wait so a broken login shell doesn't hang app launch.
let deadline = Date().addingTimeInterval(3.0)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.05)
}
if proc.isRunning {
proc.terminate()
return [:]
}
let data = (try? pipe.fileHandleForReading.readToEnd()) ?? Data()
let parts = data.split(separator: 0).map { String(data: Data($0), encoding: .utf8) ?? "" }
var out: [String: String] = [:]
var i = 0
while i + 1 < parts.count {
out[parts[i]] = parts[i + 1]
i += 2
}
return out
}()
nonisolated private static func macLoginShellSSHAgent() -> [String: String] {
macLoginShellSSHAgentCache
}
#endif
// MARK: - SQLite snapshot
public func snapshotSQLite(remotePath: String) throws -> URL {
try? FileManager.default.createDirectory(atPath: snapshotDir, withIntermediateDirectories: true)
let localPath = snapshotDir + "/state.db"
// `.backup` is WAL-safe: sqlite takes a consistent snapshot without
// blocking writers. A plain `cp` of a WAL-mode DB could corrupt.
let remoteTmp = "/tmp/scarf-snapshot-\(UUID().uuidString).db"
// sqlite3's `.backup` is a dot-command, not a CLI arg. The whole
// dot-command must be one shell argument (double-quoted) so sqlite3
// receives it as a single command; the backup path inside it is
// single-quoted so sqlite3 parses it correctly. The DB path is a
// separate shell argument and goes through `remotePathArg`
// (double-quoted, $HOME-aware) so `~/.hermes/state.db` actually
// resolves on the remote.
//
// The second sqlite3 invocation flips the snapshot out of WAL mode
// so the scp'd file is self-contained: `.backup` preserves the
// source's journal_mode in the destination header, so without this
// step the client would need the `-wal`/`-shm` sidecars too, and
// every read would fail with "unable to open database file".
//
// Final shell command on the remote:
// sqlite3 "$HOME/.hermes/state.db" ".backup '/tmp/scarf-snapshot-XYZ.db'" \
// && sqlite3 '/tmp/scarf-snapshot-XYZ.db' "PRAGMA journal_mode=DELETE;"
let backupScript = #"sqlite3 \#(Self.remotePathArg(remotePath)) ".backup '\#(remoteTmp)'" && sqlite3 '\#(remoteTmp)' "PRAGMA journal_mode=DELETE;" > /dev/null"#
let backup = try runRemoteShell(backupScript)
if backup.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: backup.exitCode, stderr: backup.stderrString)
}
// scp the backup down. scp/sftp expands `~` natively (it goes
// through the SSH file-transfer protocol, not a remote shell), so
// remoteTmp's `/tmp/...` absolute path round-trips as-is.
ensureControlDir()
var scpArgs: [String] = [
"-o", "ControlMaster=auto",
"-o", "ControlPath=\(controlDir)/%C",
"-o", "ControlPersist=600",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "LogLevel=QUIET",
"-o", "BatchMode=yes"
]
if let port = config.port { scpArgs += ["-P", String(port)] }
if let id = config.identityFile, !id.isEmpty { scpArgs += ["-i", id] }
scpArgs.append("\(hostSpec):\(remoteTmp)")
scpArgs.append(localPath)
let pull = try runLocal(executable: scpBinary, args: scpArgs, stdin: nil, timeout: 120)
// Regardless of pull outcome, try to clean up the remote tmp.
_ = try? runRemoteShell("rm -f \(Self.remotePathArg(remoteTmp))")
if pull.exitCode != 0 {
throw TransportError.classifySSHFailure(host: config.host, exitCode: pull.exitCode, stderr: pull.stderrString)
}
return URL(fileURLWithPath: localPath)
}
// MARK: - Watching
public func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent> {
// Polling: call `stat -c %Y` on all paths every 3s and yield a single
// `.anyChanged` when any mtime changed vs. the prior tick. ControlMaster
// makes each stat ~5ms so the cost is bounded.
AsyncStream { continuation in
let task = Task.detached { [self] in
var lastSignature: String = ""
while !Task.isCancelled {
// Build one shell command that stats all paths in one
// ssh round-trip. Missing paths print "0" which still
// participates correctly in change detection. Paths
// get the `~``$HOME` rewrite via remotePathArg.
let argList = paths.map { Self.remotePathArg($0) }.joined(separator: " ")
let cmd = "for p in \(argList); do stat -c %Y \"$p\" 2>/dev/null || stat -f %m \"$p\" 2>/dev/null || echo 0; done"
do {
let result = try runRemoteShell(cmd, timeout: 30)
let signature = result.stdoutString.trimmingCharacters(in: .whitespacesAndNewlines)
if !signature.isEmpty && signature != lastSignature {
if !lastSignature.isEmpty {
continuation.yield(.anyChanged)
}
lastSignature = signature
}
} catch {
// Transient failure (connection drop) skip this tick.
#if canImport(os)
Self.logger.debug("watchPaths poll failed: \(String(describing: error))")
#endif
}
try? await Task.sleep(nanoseconds: 3_000_000_000)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
// MARK: - Private helpers
/// Spawn a local process (ssh/scp/etc.) and collect its result. Mirrors
/// `LocalTransport.runProcess` duplicated rather than shared because
/// SSH-specific code paths live on this type and we want all Process
/// lifecycle in one place per transport.
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
ensureControlDir()
let proc = Process()
proc.executableURL = URL(fileURLWithPath: executable)
proc.arguments = args
// Inherit the user's shell environment so ssh can reach the
// ssh-agent socket. GUI-launched apps don't see SSH_AUTH_SOCK by
// default without this, terminal ssh works (because the user's
// shell exports it) but Scarf-launched ssh fails auth with exit 255.
proc.environment = Self.sshSubprocessEnvironment()
let stdoutPipe = Pipe()
let stderrPipe = Pipe()
let stdinPipe = Pipe()
proc.standardOutput = stdoutPipe
proc.standardError = stderrPipe
if stdin != nil { proc.standardInput = stdinPipe }
do {
try proc.run()
} catch {
throw TransportError.other(message: "Failed to launch \(executable): \(error.localizedDescription)")
}
if let stdin {
try? stdinPipe.fileHandleForWriting.write(contentsOf: stdin)
try? stdinPipe.fileHandleForWriting.close()
}
if let timeout {
let deadline = Date().addingTimeInterval(timeout)
while proc.isRunning && Date() < deadline {
Thread.sleep(forTimeInterval: 0.1)
}
if proc.isRunning {
proc.terminate()
let partial = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
throw TransportError.timeout(seconds: timeout, partialStdout: partial)
}
} else {
proc.waitUntilExit()
}
let out = (try? stdoutPipe.fileHandleForReading.readToEnd()) ?? Data()
let err = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data()
try? stdoutPipe.fileHandleForReading.close()
try? stderrPipe.fileHandleForReading.close()
try? stdinPipe.fileHandleForWriting.close()
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
}
}
@@ -0,0 +1,122 @@
import Foundation
/// Unified I/O surface shared by local and remote Hermes installations.
///
/// **Design rationale.** The services that read Hermes state (`~/.hermes/`)
/// and spawn the `hermes` CLI all boil down to a handful of primitives:
/// read/write/list files, stat file attributes, run a process to completion,
/// spawn a long-running stdio process for streaming, take a consistent DB
/// snapshot, observe file changes. `ServerTransport` exposes exactly those
/// primitives so the same service code works against either a local
/// filesystem or a remote host reached over SSH.
///
/// The primitives are deliberately **synchronous where possible** (file I/O,
/// process `run` + wait) so services don't need to become `async` end-to-end.
/// The two naturally-streaming cases log tail and ACP stdio use
/// `makeProcess` which returns a configured `Process`; services own the
/// stdio pipes and lifecycle exactly as they do today.
public protocol ServerTransport: Sendable {
/// Identifies the context this transport serves. Used for cache
/// namespacing (e.g. per-server SQLite snapshot directories).
nonisolated var contextID: ServerID { get }
/// `true` if this transport talks to a remote host over SSH.
nonisolated var isRemote: Bool { get }
// MARK: - Files
nonisolated func readFile(_ path: String) throws -> Data
/// Atomic write: the file at `path` is either the previous contents or
/// the new contents, never a partial write. Preserves `0600` mode for
/// paths that match `.env` conventions so secrets stay owner-only.
nonisolated func writeFile(_ path: String, data: Data) throws
nonisolated func fileExists(_ path: String) -> Bool
nonisolated func stat(_ path: String) -> FileStat?
nonisolated func listDirectory(_ path: String) throws -> [String]
/// Create directories including intermediates. No-op if already present.
nonisolated func createDirectory(_ path: String) throws
/// Delete a file. No-op if absent.
nonisolated func removeFile(_ path: String) throws
// MARK: - Processes
/// Run a process to completion and capture its stdout/stderr. For remote
/// transports this actually invokes `ssh host -- executable args` under
/// the hood; for local it spawns `executable` directly.
nonisolated func runProcess(
executable: String,
args: [String],
stdin: Data?,
timeout: TimeInterval?
) throws -> ProcessResult
/// Return a `Process` configured for the target already pointed at the
/// right executable with the right arguments, but **not yet started**.
/// Callers attach their own `Pipe`s and call `run()`. Used by ACPClient
/// (JSON-RPC over stdio) and by `HermesLogService`'s streaming tail.
///
/// Local: `executable` + `args` verbatim.
/// Remote: `/usr/bin/ssh` + connection flags + `[host, "--", executable, args]`.
nonisolated func makeProcess(executable: String, args: [String]) -> Process
// MARK: - SQLite
/// Return a local filesystem URL pointing at a fresh, consistent copy of
/// the SQLite database at `remotePath`. For local transports this is
/// just the remote path unchanged. For SSH transports this performs
/// `sqlite3 .backup` on the remote side and scp's the backup into
/// `~/Library/Caches/scarf/<serverID>/state.db`, returning that URL.
nonisolated func snapshotSQLite(remotePath: String) throws -> URL
// MARK: - Watching
/// Observe changes to a set of paths and yield events when any of them
/// change. Local: FSEvents. Remote: polls `stat` mtime every 3s.
nonisolated func watchPaths(_ paths: [String]) -> AsyncStream<WatchEvent>
}
/// Stat-style file metadata. `nil` (return value) means the file does not
/// exist or couldn't be queried.
public struct FileStat: Sendable, Hashable {
public let size: Int64
public let mtime: Date
public let isDirectory: Bool
public init(
size: Int64,
mtime: Date,
isDirectory: Bool
) {
self.size = size
self.mtime = mtime
self.isDirectory = isDirectory
}
}
/// Result of a one-shot process invocation.
public struct ProcessResult: Sendable {
public let exitCode: Int32
public let stdout: Data
public let stderr: Data
public init(
exitCode: Int32,
stdout: Data,
stderr: Data
) {
self.exitCode = exitCode
self.stdout = stdout
self.stderr = stderr
}
public nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" }
public nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" }
}
public enum WatchEvent: Sendable {
/// Any path in the watched set changed; implementations may coalesce
/// rapid changes into one event. Consumers should treat this as "refresh
/// whatever you were displaying" rather than expecting fine-grained
/// per-path signals.
case anyChanged
}
@@ -0,0 +1,86 @@
import Foundation
/// Typed errors surfaced by `ServerTransport` implementations. The UI
/// distinguishes these so user-visible messages can be specific
/// ("authentication failed" vs. "command failed") without having to grep
/// stderr strings.
public enum TransportError: LocalizedError {
/// `ssh`/`scp` could not reach the host or hit a protocol-level issue
/// (name resolution, connection refused, route error).
case hostUnreachable(host: String, stderr: String)
/// Remote rejected our credentials. Typically means no ssh-agent key is
/// loaded, or the loaded keys don't match any `authorized_keys` entry.
case authenticationFailed(host: String, stderr: String)
/// Remote `~/.ssh/known_hosts` fingerprint no longer matches. Blocking
/// we never auto-accept on mismatch.
case hostKeyMismatch(host: String, stderr: String)
/// The command ran on the remote but exited non-zero.
case commandFailed(exitCode: Int32, stderr: String)
/// Local filesystem operation failed (read/write/stat) with the OS error
/// message attached.
case fileIO(path: String, underlying: String)
/// Timed out waiting for a process to finish. `partialStdout` carries
/// whatever output was captured before the timer fired.
case timeout(seconds: TimeInterval, partialStdout: Data)
/// Something we didn't plan for. Fall-through bucket with enough context
/// for a bug report.
case other(message: String)
public var errorDescription: String? {
switch self {
case .hostUnreachable(let host, _):
return "Can't reach \(host). Check the hostname, network, and SSH config."
case .authenticationFailed(let host, _):
return "SSH authentication to \(host) failed. Ensure your key is loaded in ssh-agent."
case .hostKeyMismatch(let host, _):
return "Host key for \(host) has changed. Inspect ~/.ssh/known_hosts before continuing."
case .commandFailed(let code, let stderr):
// Trim stderr to a single line for the summary; full text is in
// the associated value for disclosure views.
let firstLine = stderr.split(separator: "\n").first.map(String.init) ?? ""
return "Remote command exited \(code). \(firstLine)"
case .fileIO(let path, let msg):
return "File I/O failed at \(path): \(msg)"
case .timeout(let secs, _):
return "Command timed out after \(Int(secs))s."
case .other(let msg):
return msg
}
}
/// Full stderr (if any) for display in a disclosure view. Empty string
/// when there's no additional detail worth showing.
public var diagnosticStderr: String {
switch self {
case .hostUnreachable(_, let s),
.authenticationFailed(_, let s),
.hostKeyMismatch(_, let s),
.commandFailed(_, let s):
return s
default:
return ""
}
}
/// Heuristic classifier: convert the ssh/scp stderr of a failed command
/// into a specific `TransportError`. Used by `SSHTransport` after a
/// non-zero exit. Defaults to `.commandFailed` when no known marker
/// matches.
public static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError {
let s = stderr.lowercased()
if s.contains("permission denied") || s.contains("authentication failed")
|| s.contains("publickey") && s.contains("denied") {
return .authenticationFailed(host: host, stderr: stderr)
}
if s.contains("host key verification failed")
|| s.contains("remote host identification has changed") {
return .hostKeyMismatch(host: host, stderr: stderr)
}
if s.contains("no route to host") || s.contains("connection refused")
|| s.contains("connection timed out") || s.contains("could not resolve hostname")
|| s.contains("connection closed by") && s.contains("port 22") {
return .hostUnreachable(host: host, stderr: stderr)
}
return .commandFailed(exitCode: exitCode, stderr: stderr)
}
}
@@ -0,0 +1,232 @@
import Testing
import Foundation
@testable import ScarfCore
/// Exercises the Transport types + ServerContext that moved in M0b. Same
/// contract as the M0a tests: if any `public init` drifted away from its
/// stored properties, this suite fails fast on Linux CI before a reviewer
/// has to build on a Mac.
@Suite struct M0bTransportTests {
@Test func sshConfigMemberwiseAndDefaults() {
// Only `host` is required; all other params default to nil.
let minimal = SSHConfig(host: "home.local")
#expect(minimal.host == "home.local")
#expect(minimal.user == nil)
#expect(minimal.port == nil)
#expect(minimal.identityFile == nil)
#expect(minimal.remoteHome == nil)
#expect(minimal.hermesBinaryHint == nil)
let full = SSHConfig(
host: "h",
user: "u",
port: 2222,
identityFile: "/k",
remoteHome: "/opt/hermes",
hermesBinaryHint: "/usr/local/bin/hermes"
)
#expect(full.user == "u")
#expect(full.port == 2222)
#expect(full.remoteHome == "/opt/hermes")
}
@Test func sshConfigCodableRoundTrip() throws {
let src = SSHConfig(host: "h", user: "u", port: 22, identityFile: nil, remoteHome: nil, hermesBinaryHint: nil)
let data = try JSONEncoder().encode(src)
let dec = try JSONDecoder().decode(SSHConfig.self, from: data)
#expect(dec == src)
}
@Test func serverKindCases() {
let local = ServerKind.local
let ssh = ServerKind.ssh(SSHConfig(host: "h"))
#expect(local != ssh)
if case .local = local { } else { Issue.record("expected .local") }
if case .ssh(let cfg) = ssh { #expect(cfg.host == "h") } else { Issue.record("expected .ssh") }
}
@Test func serverContextLocalIsStable() {
// The static .local has a hard-coded UUID so window-state restoration
// across launches resolves. Pin that invariant.
#expect(ServerContext.local.id == UUID(uuidString: "00000000-0000-0000-0000-000000000001"))
#expect(ServerContext.local.displayName == "Local")
#expect(ServerContext.local.isRemote == false)
}
@Test func serverContextPathsLocalVsRemote() {
let local = ServerContext.local
#expect(local.paths.isRemote == false)
// Local home picks up $HOME or NSHomeDirectory(), then appends /.hermes.
#expect(local.paths.home.hasSuffix("/.hermes"))
let remote = ServerContext(
id: UUID(),
displayName: "remote",
kind: .ssh(SSHConfig(host: "h", remoteHome: "/opt/hermes"))
)
#expect(remote.isRemote == true)
#expect(remote.paths.home == "/opt/hermes")
// Default remote home when SSHConfig.remoteHome is nil:
let remoteDefault = ServerContext(
id: UUID(),
displayName: "r2",
kind: .ssh(SSHConfig(host: "h"))
)
#expect(remoteDefault.paths.home == "~/.hermes")
}
@Test func serverContextMakeTransportDispatches() {
let local = ServerContext.local.makeTransport()
#expect(local is LocalTransport)
#expect(local.isRemote == false)
#expect(local.contextID == ServerContext.local.id)
let remoteCtx = ServerContext(
id: UUID(),
displayName: "r",
kind: .ssh(SSHConfig(host: "h"))
)
let remote = remoteCtx.makeTransport()
#expect(remote is SSHTransport)
#expect(remote.isRemote == true)
#expect(remote.contextID == remoteCtx.id)
}
@Test func fileStatMemberwise() {
let s = FileStat(size: 123, mtime: Date(timeIntervalSince1970: 100), isDirectory: false)
#expect(s.size == 123)
#expect(s.mtime == Date(timeIntervalSince1970: 100))
#expect(s.isDirectory == false)
}
@Test func processResultMemberwiseAndStringAccessors() {
let r = ProcessResult(exitCode: 0, stdout: Data("hello\n".utf8), stderr: Data("warn\n".utf8))
#expect(r.exitCode == 0)
#expect(r.stdoutString == "hello\n")
#expect(r.stderrString == "warn\n")
// Non-UTF8 bytes should still return an (empty) String, never crash.
let weird = ProcessResult(exitCode: 1, stdout: Data([0xff, 0xfe]), stderr: Data())
#expect(weird.exitCode == 1)
#expect(weird.stdoutString == "")
}
@Test func watchEventHasOnlyAnyChanged() {
// We rely on .anyChanged as the single coalesced signal. A future
// addition of fine-grained cases would break consumers that pattern-
// match exhaustively; this test guards against that.
let e = WatchEvent.anyChanged
switch e {
case .anyChanged: break
}
}
@Test func localTransportConstructsWithDefaultID() {
let t = LocalTransport()
#expect(t.isRemote == false)
#expect(t.contextID == ServerContext.local.id)
let explicit = LocalTransport(contextID: UUID())
#expect(explicit.contextID != ServerContext.local.id)
}
@Test func sshTransportStaticPathsAreStable() {
// controlDirPath() is used by Mac tests (`ControlPathTests`) to check
// the macOS 104-byte sun_path limit. Pin the format here so the
// per-uid suffix never drifts away.
let dir = SSHTransport.controlDirPath()
#expect(dir.hasPrefix("/tmp/scarf-ssh-"))
let id = UUID()
let snapshot = SSHTransport.snapshotDirPath(for: id)
#expect(snapshot.contains(id.uuidString))
#expect(snapshot.hasSuffix("/scarf/snapshots/\(id.uuidString)"))
let root = SSHTransport.snapshotRootPath()
#expect(root.hasSuffix("/scarf/snapshots"))
}
@Test func sshTransportConstructsWithConfig() {
let cfg = SSHConfig(host: "box.local", user: "alan")
let t = SSHTransport(contextID: UUID(), config: cfg, displayName: "Home")
#expect(t.isRemote == true)
#expect(t.config.host == "box.local")
#expect(t.displayName == "Home")
}
@Test func transportErrorDescriptionsAreUserFacing() {
#expect(TransportError.hostUnreachable(host: "h", stderr: "").errorDescription?.contains("h") == true)
#expect(TransportError.authenticationFailed(host: "h", stderr: "").errorDescription?.contains("authentication") == true)
#expect(TransportError.hostKeyMismatch(host: "h", stderr: "").errorDescription?.contains("Host key") == true)
#expect(TransportError.commandFailed(exitCode: 7, stderr: "no such file").errorDescription?.contains("7") == true)
#expect(TransportError.fileIO(path: "/p", underlying: "boom").errorDescription?.contains("/p") == true)
#expect(TransportError.timeout(seconds: 10, partialStdout: Data()).errorDescription?.contains("10") == true)
#expect(TransportError.other(message: "x").errorDescription == "x")
}
@Test func transportErrorClassifierHandlesKnownStderrPatterns() {
let auth = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "Permission denied (publickey).")
if case .authenticationFailed = auth {} else { Issue.record("expected authFailed") }
let mismatch = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "Host key verification failed.")
if case .hostKeyMismatch = mismatch {} else { Issue.record("expected hostKeyMismatch") }
let unreach = TransportError.classifySSHFailure(
host: "h", exitCode: 255,
stderr: "ssh: connect to host h port 22: Connection refused")
if case .hostUnreachable = unreach {} else { Issue.record("expected hostUnreachable") }
let generic = TransportError.classifySSHFailure(
host: "h", exitCode: 1, stderr: "random failure")
if case .commandFailed = generic {} else { Issue.record("expected commandFailed") }
}
@Test func transportErrorDiagnosticStderr() {
#expect(TransportError.hostUnreachable(host: "h", stderr: "detail").diagnosticStderr == "detail")
#expect(TransportError.timeout(seconds: 1, partialStdout: Data()).diagnosticStderr == "")
#expect(TransportError.other(message: "x").diagnosticStderr == "")
}
@Test func serverContextCachesInvalidation() async {
// Seed the process-wide home-cache for a made-up server, then invalidate.
// The .local path doesn't hit the cache (isRemote == false), so we use a
// remote context its .resolvedUserHome() would do an SSH probe, which
// we can't run here. We just assert the invalidate API is callable.
let ctxID = UUID()
await ServerContext.invalidateCaches(for: ctxID)
}
@Test func localTransportFileRoundTrip() throws {
let tmp = FileManager.default.temporaryDirectory.appendingPathComponent("scarftest-\(UUID().uuidString).txt")
defer { try? FileManager.default.removeItem(at: tmp) }
let transport = LocalTransport()
let content = Data("hello scarf\n".utf8)
try transport.writeFile(tmp.path, data: content)
#expect(transport.fileExists(tmp.path))
let read = try transport.readFile(tmp.path)
#expect(read == content)
let stat = transport.stat(tmp.path)
#expect(stat != nil)
#expect(stat?.size == Int64(content.count))
#expect(stat?.isDirectory == false)
try transport.removeFile(tmp.path)
#expect(!transport.fileExists(tmp.path))
// Re-remove is a no-op, not a throw.
try transport.removeFile(tmp.path)
}
@Test func localTransportSnapshotSQLiteReturnsPathUnchanged() throws {
let transport = LocalTransport()
let url = try transport.snapshotSQLite(remotePath: "/tmp/some/state.db")
#expect(url.path == "/tmp/some/state.db")
}
}