mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user