From 0fd2ceb9fca53ba03d6c07f46bf46cccbec07aa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Apr 2026 22:06:36 +0000 Subject: [PATCH] iOS port M0b: extract Transport + ServerContext to ScarfCore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../ScarfCore}/Models/ServerContext.swift | 122 ++++----- .../ScarfCore}/Transport/LocalTransport.swift | 54 ++-- .../ScarfCore}/Transport/SSHTransport.swift | 119 +++++++-- .../Transport/ServerTransport.swift | 44 +++- .../Transport/TransportErrors.swift | 8 +- .../ScarfCoreTests/M0bTransportTests.swift | 232 ++++++++++++++++++ scarf/docs/IOS_PORT_PLAN.md | 80 +++++- .../Core/Models/HermesPaths+Deprecated.swift | 74 ------ .../scarf/Core/Models/ServerContext+Mac.swift | 41 ++++ .../Core/Persistence/ServerRegistry.swift | 1 + .../Core/Services/HermesEnvService.swift | 1 + .../Core/Services/HermesFileWatcher.swift | 1 + .../Core/Services/ModelCatalogService.swift | 1 + .../ViewModels/CredentialPoolsViewModel.swift | 1 + .../ViewModels/OAuthFlowController.swift | 1 + .../Views/CredentialPoolsView.swift | 1 + .../Health/ViewModels/HealthViewModel.swift | 1 + .../Features/Health/Views/HealthView.swift | 1 + .../Logs/ViewModels/LogsViewModel.swift | 1 + .../scarf/Features/Logs/Views/LogsView.swift | 1 + .../Memory/ViewModels/MemoryViewModel.swift | 1 + .../Features/Memory/Views/MemoryView.swift | 1 + .../ViewModels/PersonalitiesViewModel.swift | 1 + .../Views/PersonalitiesView.swift | 1 + .../PlatformSetup/EmailSetupViewModel.swift | 1 + .../PlatformSetup/FeishuSetupViewModel.swift | 1 + .../HomeAssistantSetupViewModel.swift | 1 + .../IMessageSetupViewModel.swift | 1 + .../PlatformSetup/MatrixSetupViewModel.swift | 1 + .../MattermostSetupViewModel.swift | 1 + .../PlatformSetup/PlatformSetupHelpers.swift | 1 + .../PlatformSetup/SignalSetupViewModel.swift | 1 + .../PlatformSetup/SlackSetupViewModel.swift | 1 + .../TelegramSetupViewModel.swift | 1 + .../PlatformSetup/WebhookSetupViewModel.swift | 1 + .../WhatsAppSetupViewModel.swift | 1 + .../PlatformSetup/DiscordSetupView.swift | 1 + .../Views/PlatformSetup/EmailSetupView.swift | 1 + .../Views/PlatformSetup/FeishuSetupView.swift | 1 + .../HomeAssistantSetupView.swift | 1 + .../PlatformSetup/IMessageSetupView.swift | 1 + .../Views/PlatformSetup/MatrixSetupView.swift | 1 + .../PlatformSetup/MattermostSetupView.swift | 1 + .../Views/PlatformSetup/SignalSetupView.swift | 1 + .../Views/PlatformSetup/SlackSetupView.swift | 1 + .../PlatformSetup/TelegramSetupView.swift | 1 + .../PlatformSetup/WebhookSetupView.swift | 1 + .../PlatformSetup/WhatsAppSetupView.swift | 1 + .../Plugins/ViewModels/PluginsViewModel.swift | 1 + .../Features/Plugins/Views/PluginsView.swift | 1 + .../ViewModels/ProfilesViewModel.swift | 1 + .../Profiles/Views/ProfilesView.swift | 1 + .../ViewModels/QuickCommandsViewModel.swift | 1 + .../Views/QuickCommandsView.swift | 1 + .../ViewModels/AddServerViewModel.swift | 1 + .../ConnectionStatusViewModel.swift | 1 + .../RemoteDiagnosticsViewModel.swift | 1 + .../Servers/Views/AddServerSheet.swift | 1 + .../Servers/Views/ManageServersView.swift | 1 + .../Servers/Views/MissingServerView.swift | 1 + .../Servers/Views/RemoteDiagnostics.swift | 1 + .../Servers/Views/ServerSwitcherToolbar.swift | 1 + .../Settings/Views/SettingsView.swift | 1 + .../Features/Skills/Views/SkillsView.swift | 1 + .../ViewModels/WebhooksViewModel.swift | 1 + .../Webhooks/Views/WebhooksView.swift | 1 + scarf/scarf/scarfApp.swift | 1 + scarf/scarfTests/scarfTests.swift | 1 + 68 files changed, 645 insertions(+), 188 deletions(-) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Models/ServerContext.swift (73%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Transport/LocalTransport.swift (78%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Transport/SSHTransport.swift (86%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Transport/ServerTransport.swift (81%) rename scarf/{scarf/Core => Packages/ScarfCore/Sources/ScarfCore}/Transport/TransportErrors.swift (94%) create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift delete mode 100644 scarf/scarf/Core/Models/HermesPaths+Deprecated.swift create mode 100644 scarf/scarf/Core/Models/ServerContext+Mac.swift diff --git a/scarf/scarf/Core/Models/ServerContext.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift similarity index 73% rename from scarf/scarf/Core/Models/ServerContext.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift index 9510a8c..88a0d0c 100644 --- a/scarf/scarf/Core/Models/ServerContext.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ServerContext.swift @@ -1,65 +1,90 @@ import Foundation -import ScarfCore +#if canImport(SwiftUI) import SwiftUI -import AppKit +#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. -typealias ServerID = UUID +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. -struct SSHConfig: Sendable, Hashable, Codable { +public struct SSHConfig: Sendable, Hashable, Codable { /// Hostname or `~/.ssh/config` alias. - var host: String + public var host: String /// Remote username. `nil` → defer to `~/.ssh/config` or the local user. - var user: String? + public var user: String? /// TCP port. `nil` → 22 (or whatever `~/.ssh/config` says). - var port: Int? + public var port: Int? /// Absolute path to a private key. `nil` → defer to ssh-agent / /// `~/.ssh/config` identity files. - var identityFile: String? + public var identityFile: String? /// Override for the remote `$HOME/.hermes` directory. `nil` uses /// `HermesPathSet.defaultRemoteHome` (`~/.hermes`, shell-expanded on the /// remote side). - var remoteHome: String? + 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. - var hermesBinaryHint: String? + 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. -enum ServerKind: Sendable, Hashable, Codable { +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 in Phase 1. One `ServerContext` corresponds to -/// one Hermes installation; multi-window scenes in Phase 3 will construct -/// one per window. +/// every service and ViewModel. One `ServerContext` corresponds to one +/// Hermes installation; multi-window scenes construct one per window. /// -/// **Why every member is `nonisolated`.** This file imports `AppKit` +/// **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 -/// upcoming default-isolation rules pulls the whole struct to `@MainActor`. +/// 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; the one MainActor-dependent method -/// (`openInLocalEditor`) lives in the extension below. -struct ServerContext: Sendable, Hashable, Identifiable { - let id: ServerID - var displayName: String - var kind: ServerKind +/// 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. - nonisolated var paths: HermesPathSet { + public nonisolated var paths: HermesPathSet { switch kind { case .local: return HermesPathSet( @@ -76,7 +101,7 @@ struct ServerContext: Sendable, Hashable, Identifiable { } } - nonisolated var isRemote: Bool { + public nonisolated var isRemote: Bool { if case .ssh = kind { return true } return false } @@ -85,7 +110,7 @@ struct ServerContext: Sendable, Hashable, Identifiable { /// a `LocalTransport`; SSH contexts get an `SSHTransport` configured /// from `SSHConfig`. Each call returns a fresh value — transports are /// cheap and stateless beyond disk caches. - nonisolated func makeTransport() -> any ServerTransport { + public nonisolated func makeTransport() -> any ServerTransport { switch kind { case .local: return LocalTransport(contextID: id) @@ -104,7 +129,7 @@ struct ServerContext: Sendable, Hashable, Identifiable { /// The default "this machine" context. Used everywhere in Phase 0/1 and /// remains the fallback when no remote server is selected. - nonisolated static let local = ServerContext( + public nonisolated static let local = ServerContext( id: localID, displayName: "Local", kind: .local @@ -155,13 +180,13 @@ extension ServerContext { /// 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. - func resolvedUserHome() async -> String { + 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. - static func invalidateCaches(for contextID: ServerID) async { + public static func invalidateCaches(for contextID: ServerID) async { await UserHomeCache.shared.invalidate(contextID: contextID) } } @@ -179,20 +204,20 @@ extension ServerContext { extension ServerContext { /// Read a UTF-8 text file. `nil` on any error (missing, transport down, /// invalid encoding). - nonisolated func readText(_ path: String) -> String? { + 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. - nonisolated func readData(_ path: String) -> Data? { + 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 - nonisolated func writeText(_ path: String, content: String) -> Bool { + 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) @@ -203,37 +228,14 @@ extension ServerContext { } /// Existence check. Local: `FileManager`. Remote: `ssh test -e`. - nonisolated func fileExists(_ path: String) -> Bool { + public nonisolated func fileExists(_ path: String) -> Bool { makeTransport().fileExists(path) } /// File modification timestamp, or `nil` if the file doesn't exist. - nonisolated func modificationDate(_ path: String) -> Date? { + public nonisolated func modificationDate(_ path: String) -> Date? { makeTransport().stat(path)?.mtime } - - /// Invoke the `hermes` CLI on this server and return its combined output - /// + exit code. Local: spawns the local binary via `Process`. Remote: - /// rounds through `ssh host hermes …`. Use this from any VM that needs - /// to fire off a CLI command — never spawn `hermes` via `Process()` - /// directly, because that path bypasses the transport for remote. - @discardableResult - nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) { - let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin) - return (result.output, result.exitCode) - } - - /// Reveal the file at `path` in the user's local editor (via - /// `NSWorkspace.open`). For remote contexts this is a no-op — the - /// file doesn't exist on this Mac, so opening it would fail silently - /// or worse, open the wrong file from the local filesystem. - /// Returns `true` if opened, `false` if the call was skipped. - @discardableResult - func openInLocalEditor(_ path: String) -> Bool { - guard !isRemote else { return false } - NSWorkspace.shared.open(URL(fileURLWithPath: path)) - return true - } } // MARK: - SwiftUI environment plumbing @@ -242,13 +244,19 @@ extension ServerContext { /// 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 { - var serverContext: ServerContext { + public var serverContext: ServerContext { get { self[ServerContextEnvironmentKey.self] } set { self[ServerContextEnvironmentKey.self] = newValue } } } +#endif diff --git a/scarf/scarf/Core/Transport/LocalTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift similarity index 78% rename from scarf/scarf/Core/Transport/LocalTransport.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift index 9db526d..5d9721c 100644 --- a/scarf/scarf/Core/Transport/LocalTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/LocalTransport.swift @@ -1,22 +1,33 @@ 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. -struct LocalTransport: ServerTransport { +/// +/// **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 - let contextID: ServerID - let isRemote: Bool = false + public let contextID: ServerID + public let isRemote: Bool = false - nonisolated init(contextID: ServerID = ServerContext.local.id) { + public nonisolated init(contextID: ServerID = ServerContext.local.id) { self.contextID = contextID } // MARK: - Files - func readFile(_ path: String) throws -> Data { + public func readFile(_ path: String) throws -> Data { do { return try Data(contentsOf: URL(fileURLWithPath: path)) } catch { @@ -24,7 +35,7 @@ struct LocalTransport: ServerTransport { } } - func writeFile(_ path: String, data: Data) throws { + public func writeFile(_ path: String, data: Data) throws { let tmp = path + ".scarf.tmp" do { try data.write(to: URL(fileURLWithPath: tmp)) @@ -54,11 +65,11 @@ struct LocalTransport: ServerTransport { } } - func fileExists(_ path: String) -> Bool { + public func fileExists(_ path: String) -> Bool { FileManager.default.fileExists(atPath: path) } - func stat(_ path: String) -> FileStat? { + public func stat(_ path: String) -> FileStat? { guard let attrs = try? FileManager.default.attributesOfItem(atPath: path) else { return nil } @@ -68,7 +79,7 @@ struct LocalTransport: ServerTransport { return FileStat(size: size, mtime: mtime, isDirectory: isDir) } - func listDirectory(_ path: String) throws -> [String] { + public func listDirectory(_ path: String) throws -> [String] { do { return try FileManager.default.contentsOfDirectory(atPath: path) } catch { @@ -76,7 +87,7 @@ struct LocalTransport: ServerTransport { } } - func createDirectory(_ path: String) throws { + public func createDirectory(_ path: String) throws { do { try FileManager.default.createDirectory(atPath: path, withIntermediateDirectories: true) } catch { @@ -84,7 +95,7 @@ struct LocalTransport: ServerTransport { } } - func removeFile(_ path: String) throws { + public func removeFile(_ path: String) throws { guard FileManager.default.fileExists(atPath: path) else { return } do { try FileManager.default.removeItem(atPath: path) @@ -95,7 +106,7 @@ struct LocalTransport: ServerTransport { // MARK: - Processes - func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { + public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { let proc = Process() proc.executableURL = URL(fileURLWithPath: executable) proc.arguments = args @@ -138,7 +149,7 @@ struct LocalTransport: ServerTransport { return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err) } - func makeProcess(executable: String, args: [String]) -> Process { + public func makeProcess(executable: String, args: [String]) -> Process { let proc = Process() proc.executableURL = URL(fileURLWithPath: executable) proc.arguments = args @@ -147,14 +158,15 @@ struct LocalTransport: ServerTransport { // MARK: - SQLite - func snapshotSQLite(remotePath: String) throws -> URL { + public func snapshotSQLite(remotePath: String) throws -> URL { // Local case: no copy needed. Services open the path directly. URL(fileURLWithPath: remotePath) } // MARK: - Watching - func watchPaths(_ paths: [String]) -> AsyncStream { + #if canImport(Darwin) + public func watchPaths(_ paths: [String]) -> AsyncStream { AsyncStream { continuation in // Build the source list immutably, then hand a value-typed copy // to onTermination. Swift 6's concurrent-capture rule rejects a @@ -178,6 +190,18 @@ struct LocalTransport: ServerTransport { } } } + #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 { + AsyncStream { continuation in + continuation.finish() + } + } + #endif // MARK: - Helpers diff --git a/scarf/scarf/Core/Transport/SSHTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift similarity index 86% rename from scarf/scarf/Core/Transport/SSHTransport.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift index 42afcf5..c2642cd 100644 --- a/scarf/scarf/Core/Transport/SSHTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/SSHTransport.swift @@ -1,5 +1,7 @@ import Foundation +#if canImport(os) import os +#endif /// `ServerTransport` that reaches a remote Hermes installation through the /// system `ssh`, `scp`, and `sftp` binaries. @@ -15,16 +17,18 @@ import os /// 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. -struct SSHTransport: ServerTransport { +public struct SSHTransport: ServerTransport { + #if canImport(os) nonisolated private static let logger = Logger(subsystem: "com.scarf", category: "SSHTransport") + #endif - let contextID: ServerID - let isRemote: Bool = true + public let contextID: ServerID + public let isRemote: Bool = true - let config: SSHConfig - let displayName: String + public let config: SSHConfig + public let displayName: String - nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) { + public nonisolated init(contextID: ServerID, config: SSHConfig, displayName: String) { self.contextID = contextID self.config = config self.displayName = displayName @@ -57,14 +61,14 @@ struct SSHTransport: ServerTransport { /// 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. - nonisolated static func controlDirPath() -> String { + 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. - nonisolated static func snapshotDirPath(for contextID: ServerID) -> String { + 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)" @@ -72,7 +76,7 @@ struct SSHTransport: ServerTransport { /// Root of the snapshot cache (all servers). Used by the app-launch sweep /// that prunes dirs whose UUID no longer appears in the registry. - nonisolated static func snapshotRootPath() -> String { + public nonisolated static func snapshotRootPath() -> String { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.path ?? NSHomeDirectory() + "/Library/Caches" return base + "/scarf/snapshots" @@ -80,7 +84,7 @@ struct SSHTransport: ServerTransport { /// Remove the snapshot directory for a server (no-op if absent). Called /// on `removeServer` and on app-launch for orphaned dirs. - static func pruneSnapshotCache(for contextID: ServerID) { + public static func pruneSnapshotCache(for contextID: ServerID) { let dir = snapshotDirPath(for: contextID) try? FileManager.default.removeItem(atPath: dir) } @@ -88,7 +92,7 @@ struct SSHTransport: ServerTransport { /// 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. - static func sweepOrphanSnapshots(keeping keep: Set) { + public static func sweepOrphanSnapshots(keeping keep: Set) { let root = snapshotRootPath() guard let entries = try? FileManager.default.contentsOfDirectory(atPath: root) else { return } for name in entries { @@ -107,7 +111,7 @@ struct SSHTransport: ServerTransport { /// Wiping these on launch keeps `/tmp/scarf-ssh-/` from accumulating /// indefinitely until reboot, while leaving any concurrent Scarf /// instance's live sockets (always <600s old) untouched. - static func sweepStaleControlSockets(staleAfter: TimeInterval = 1800) { + 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) @@ -127,7 +131,7 @@ struct SSHTransport: ServerTransport { /// 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. - func closeControlMaster() { + public func closeControlMaster() { ensureControlDir() let args = sshArgs(extra: ["-O", "exit", hostSpec]) _ = try? runLocal(executable: sshBinary, args: args, stdin: nil, timeout: 10) @@ -177,6 +181,7 @@ struct SSHTransport: ServerTransport { /// 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) } @@ -206,6 +211,12 @@ struct SSHTransport: ServerTransport { 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 @@ -262,7 +273,7 @@ struct SSHTransport: ServerTransport { // MARK: - Files - func readFile(_ path: String) throws -> Data { + 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). @@ -280,7 +291,7 @@ struct SSHTransport: ServerTransport { return result.stdout } - func writeFile(_ path: String, data: Data) throws { + public func writeFile(_ path: String, data: Data) throws { // Atomic pattern: // 1. scp to `.scarf.tmp` on the remote // 2. ssh `mv ` — atomic on POSIX within the same FS @@ -329,14 +340,14 @@ struct SSHTransport: ServerTransport { } } - func fileExists(_ path: String) -> Bool { + public func fileExists(_ path: String) -> Bool { guard let result = try? runRemoteShell("test -e \(Self.remotePathArg(path))") else { return false } return result.exitCode == 0 } - func stat(_ path: String) -> FileStat? { + 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 @@ -366,7 +377,7 @@ struct SSHTransport: ServerTransport { return FileStat(size: size, mtime: Date(timeIntervalSince1970: mtimeSecs), isDirectory: isDir) } - func listDirectory(_ path: String) throws -> [String] { + 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))") @@ -381,14 +392,14 @@ struct SSHTransport: ServerTransport { .map(String.init) } - func createDirectory(_ path: String) throws { + 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) } } - func removeFile(_ path: String) throws { + 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) @@ -397,7 +408,7 @@ struct SSHTransport: ServerTransport { // MARK: - Processes - func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { + public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult { // Wrap in `sh -c ' '` with `~/`-rewritten paths so // home-relative args expand on the remote. The executable might be // `~/.local/bin/hermes` or just `hermes`; either survives. @@ -410,7 +421,7 @@ struct SSHTransport: ServerTransport { return try runLocal(executable: sshBinary, args: sshArgv, stdin: stdin, timeout: timeout) } - func makeProcess(executable: String, args: [String]) -> Process { + 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 @@ -433,20 +444,76 @@ struct SSHTransport: ServerTransport { /// 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 - let shellEnv = HermesFileService.enrichedEnvironment() + #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 - func snapshotSQLite(remotePath: String) throws -> URL { + 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 @@ -501,7 +568,7 @@ struct SSHTransport: ServerTransport { // MARK: - Watching - func watchPaths(_ paths: [String]) -> AsyncStream { + public func watchPaths(_ paths: [String]) -> AsyncStream { // 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. @@ -526,7 +593,9 @@ struct SSHTransport: ServerTransport { } } 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) } diff --git a/scarf/scarf/Core/Transport/ServerTransport.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift similarity index 81% rename from scarf/scarf/Core/Transport/ServerTransport.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift index 38334d3..59538f7 100644 --- a/scarf/scarf/Core/Transport/ServerTransport.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/ServerTransport.swift @@ -15,7 +15,7 @@ import Foundation /// 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. -protocol ServerTransport: Sendable { +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 } @@ -77,23 +77,43 @@ protocol ServerTransport: Sendable { /// Stat-style file metadata. `nil` (return value) means the file does not /// exist or couldn't be queried. -struct FileStat: Sendable, Hashable { - let size: Int64 - let mtime: Date - let isDirectory: Bool +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. -struct ProcessResult: Sendable { - let exitCode: Int32 - let stdout: Data - let stderr: Data +public struct ProcessResult: Sendable { + public let exitCode: Int32 + public let stdout: Data + public let stderr: Data - nonisolated var stdoutString: String { String(data: stdout, encoding: .utf8) ?? "" } - nonisolated var stderrString: String { String(data: stderr, encoding: .utf8) ?? "" } + + 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) ?? "" } } -enum WatchEvent: Sendable { +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 diff --git a/scarf/scarf/Core/Transport/TransportErrors.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/TransportErrors.swift similarity index 94% rename from scarf/scarf/Core/Transport/TransportErrors.swift rename to scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/TransportErrors.swift index f81cb9b..621b566 100644 --- a/scarf/scarf/Core/Transport/TransportErrors.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Transport/TransportErrors.swift @@ -4,7 +4,7 @@ import Foundation /// distinguishes these so user-visible messages can be specific /// ("authentication failed" vs. "command failed") without having to grep /// stderr strings. -enum TransportError: LocalizedError { +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) @@ -26,7 +26,7 @@ enum TransportError: LocalizedError { /// for a bug report. case other(message: String) - var errorDescription: String? { + public var errorDescription: String? { switch self { case .hostUnreachable(let host, _): return "Can't reach \(host). Check the hostname, network, and SSH config." @@ -50,7 +50,7 @@ enum TransportError: LocalizedError { /// Full stderr (if any) for display in a disclosure view. Empty string /// when there's no additional detail worth showing. - var diagnosticStderr: String { + public var diagnosticStderr: String { switch self { case .hostUnreachable(_, let s), .authenticationFailed(_, let s), @@ -66,7 +66,7 @@ enum TransportError: LocalizedError { /// into a specific `TransportError`. Used by `SSHTransport` after a /// non-zero exit. Defaults to `.commandFailed` when no known marker /// matches. - static func classifySSHFailure(host: String, exitCode: Int32, stderr: String) -> TransportError { + 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") { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift new file mode 100644 index 0000000..c677115 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0bTransportTests.swift @@ -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") + } +} diff --git a/scarf/docs/IOS_PORT_PLAN.md b/scarf/docs/IOS_PORT_PLAN.md index 680767e..aa50744 100644 --- a/scarf/docs/IOS_PORT_PLAN.md +++ b/scarf/docs/IOS_PORT_PLAN.md @@ -224,7 +224,85 @@ $PWD/scarf/Packages/ScarfCore:/work -w /work swift:6.0 swift test`. `nonisolated` to new ScarfCore APIs pre-emptively; match the surrounding conventions. -### M0b — pending +### M0b — shipped + +**Shipped:** + +- 4 Transport files moved to `Packages/ScarfCore/Sources/ScarfCore/Transport/`: + `ServerTransport.swift`, `LocalTransport.swift`, `SSHTransport.swift`, + `TransportErrors.swift`. +- `ServerContext.swift` moved to `Packages/ScarfCore/Sources/ScarfCore/Models/`. + The `runHermes(_:timeout:stdin:)` and `openInLocalEditor(_:)` extension + methods — the only two that depend on main-target `HermesFileService` or + on AppKit's `NSWorkspace` — are split out into a new main-target file + `scarf/Core/Models/ServerContext+Mac.swift`. +- `HermesFileService.enrichedEnvironment()` reference inside + `SSHTransport.sshSubprocessEnvironment()` replaced with a local + `#if os(macOS)` helper `macLoginShellSSHAgent()` that does a narrow + `zsh -l -c` probe for only `SSH_AUTH_SOCK` / `SSH_AGENT_PID` (instead + of the broader PATH + credentials harvest that still lives in + `HermesFileService`). This breaks the Mac-target dependency from + ScarfCore. Behavior-identical on macOS; a no-op on iOS (where the SSH + agent comes from Citadel in M4, not the user's shell) and on Linux CI. +- `HermesPaths+Deprecated.swift` deleted. Its only justification was that + `ServerContext` was in the Mac target; with `ServerContext` in ScarfCore + now, the deprecated forwarders are both unreachable AND unused (zero + callers). Good riddance. +- Added `import ScarfCore` to 54 more consumer files that reference + Transport types or `ServerContext` but weren't already importing + ScarfCore from M0a. `scarfTests/scarfTests.swift` also gets the import + — its `ControlPathTests` now hits the public `SSHTransport` via + ScarfCore. + +**Platform guards applied in ScarfCore:** + +- `#if canImport(os)` — Apple's `os.Logger` (`import os` + every call + site). Linux gets silent logging. **Exception:** the large block in + `SSHTransport.ensureControlDir()` uses `Darwin.stat` / `lstat` / `mkdir` + / `chmod` alongside its Logger calls — the whole method body is wrapped + in `#if canImport(Darwin)` with a simple `FileManager.createDirectory` + fallback for Linux (stubbed because SSH isn't exercised at runtime on + Linux anyway). +- `#if canImport(Darwin)` — `Darwin.open`/`Darwin.close` + FSEvents-based + `DispatchSourceFileSystemObject` in `LocalTransport.watchPaths`. Linux + gets a no-op empty stream. +- `#if canImport(SwiftUI)` — `EnvironmentKey` / `EnvironmentValues` + plumbing in `ServerContext.swift`. +- `#if canImport(AppKit)` — only in the split-out + `ServerContext+Mac.swift`, where `NSWorkspace.shared.open` lives. iOS + will provide its own equivalent (`UIApplication.open(_:)`) when the + target lands in M2. + +**Bug fixed while moving:** the sed transform in M0a accidentally promoted +`protocol ServerTransport` requirements to `public nonisolated var contextID ...`. +Protocol requirements inherit the protocol's access level and **must +not** carry an explicit modifier — that's a Swift compile error. Fixed +in this PR's ServerTransport.swift. + +**Test coverage (`M0bTransportTests`):** 18 new tests that construct +`SSHConfig` with and without defaults, round-trip it through Codable, +verify `ServerKind` pattern-matching, pin `ServerContext.local`'s +hard-coded UUID, assert local-vs-remote path derivation, verify +`makeTransport()` dispatches to the right impl, exercise `FileStat` / +`ProcessResult` / `WatchEvent` / `TransportError` shapes + error-classifier +stderr patterns, and round-trip an actual local file through +`LocalTransport` (write → read → stat → remove). + +**Rules next phases can rely on:** + +- `ServerContext` is the canonical multi-server entry point. Any new + service added in M0c or later takes a `ServerContext` in its init. +- `ServerContext+Mac.swift` is the pattern for Mac-only methods on + ScarfCore types. iOS will have a sibling `ServerContext+iOS.swift` + when the iOS target lands. Keep platform-specific methods out of + ScarfCore itself and in these sibling files. +- Logger pattern: `#if canImport(os) ... #endif` around each call site. + If there are 3+ sites in one method, consider wrapping the whole method + body in `#if canImport(Darwin)` with a Linux-safe fallback. +- SSH env enrichment is now self-contained in `SSHTransport.swift`. When + iOS's Citadel-based transport lands (M4), it will provide its own env + story — the existing macOS helper stays untouched. + ### M0c — pending ### M0d — pending ### M1 — pending diff --git a/scarf/scarf/Core/Models/HermesPaths+Deprecated.swift b/scarf/scarf/Core/Models/HermesPaths+Deprecated.swift deleted file mode 100644 index c07d02a..0000000 --- a/scarf/scarf/Core/Models/HermesPaths+Deprecated.swift +++ /dev/null @@ -1,74 +0,0 @@ -import Foundation -import ScarfCore - -/// Deprecated module-level path statics. Preserved as thin forwarders to -/// `ServerContext.local.paths` so existing call sites continue to compile -/// while Phase 1 migrates them to a per-server `ServerContext`. -/// -/// New code should accept a `ServerContext` and read `context.paths.`. -/// -/// **Staying behind in the Mac target**: this enum references -/// `ServerContext.local`, which currently lives in the Mac target (not yet -/// extracted to `ScarfCore` — that move is part of M0b). Once `ServerContext` -/// moves, this file can be deleted or moved alongside it. Until then, leaving -/// it here keeps the Mac build behavior unchanged. -enum HermesPaths: Sendable { - @available(*, deprecated, message: "use ServerContext.paths.home") - nonisolated static var home: String { ServerContext.local.paths.home } - - @available(*, deprecated, message: "use ServerContext.paths.stateDB") - nonisolated static var stateDB: String { ServerContext.local.paths.stateDB } - - @available(*, deprecated, message: "use ServerContext.paths.configYAML") - nonisolated static var configYAML: String { ServerContext.local.paths.configYAML } - - @available(*, deprecated, message: "use ServerContext.paths.memoriesDir") - nonisolated static var memoriesDir: String { ServerContext.local.paths.memoriesDir } - - @available(*, deprecated, message: "use ServerContext.paths.memoryMD") - nonisolated static var memoryMD: String { ServerContext.local.paths.memoryMD } - - @available(*, deprecated, message: "use ServerContext.paths.userMD") - nonisolated static var userMD: String { ServerContext.local.paths.userMD } - - @available(*, deprecated, message: "use ServerContext.paths.sessionsDir") - nonisolated static var sessionsDir: String { ServerContext.local.paths.sessionsDir } - - @available(*, deprecated, message: "use ServerContext.paths.cronJobsJSON") - nonisolated static var cronJobsJSON: String { ServerContext.local.paths.cronJobsJSON } - - @available(*, deprecated, message: "use ServerContext.paths.cronOutputDir") - nonisolated static var cronOutputDir: String { ServerContext.local.paths.cronOutputDir } - - @available(*, deprecated, message: "use ServerContext.paths.gatewayStateJSON") - nonisolated static var gatewayStateJSON: String { ServerContext.local.paths.gatewayStateJSON } - - @available(*, deprecated, message: "use ServerContext.paths.skillsDir") - nonisolated static var skillsDir: String { ServerContext.local.paths.skillsDir } - - @available(*, deprecated, message: "use ServerContext.paths.errorsLog") - nonisolated static var errorsLog: String { ServerContext.local.paths.errorsLog } - - @available(*, deprecated, message: "use ServerContext.paths.agentLog") - nonisolated static var agentLog: String { ServerContext.local.paths.agentLog } - - @available(*, deprecated, message: "use ServerContext.paths.gatewayLog") - nonisolated static var gatewayLog: String { ServerContext.local.paths.gatewayLog } - - @available(*, deprecated, message: "use ServerContext.paths.scarfDir") - nonisolated static var scarfDir: String { ServerContext.local.paths.scarfDir } - - @available(*, deprecated, message: "use ServerContext.paths.projectsRegistry") - nonisolated static var projectsRegistry: String { ServerContext.local.paths.projectsRegistry } - - @available(*, deprecated, message: "use ServerContext.paths.mcpTokensDir") - nonisolated static var mcpTokensDir: String { ServerContext.local.paths.mcpTokensDir } - - @available(*, deprecated, message: "use HermesPathSet.hermesBinaryCandidates") - nonisolated static var hermesBinaryCandidates: [String] { - HermesPathSet.hermesBinaryCandidates - } - - @available(*, deprecated, message: "use ServerContext.paths.hermesBinary") - nonisolated static var hermesBinary: String { ServerContext.local.paths.hermesBinary } -} diff --git a/scarf/scarf/Core/Models/ServerContext+Mac.swift b/scarf/scarf/Core/Models/ServerContext+Mac.swift new file mode 100644 index 0000000..3046170 --- /dev/null +++ b/scarf/scarf/Core/Models/ServerContext+Mac.swift @@ -0,0 +1,41 @@ +import Foundation +import ScarfCore +#if canImport(AppKit) +import AppKit +#endif + +/// `ServerContext` extensions that depend on main-target services +/// (`HermesFileService`) or macOS-only frameworks (`AppKit.NSWorkspace`). +/// +/// These stay in the Mac app target so `ScarfCore` itself has no dependency +/// on AppKit or on the Mac app's services. The iOS target will provide its +/// own equivalents (or skip these features entirely) via its own +/// `ServerContext+iOS.swift` when it lands in M2+. +extension ServerContext { + /// Invoke the `hermes` CLI on this server and return its combined output + /// + exit code. Local: spawns the local binary via `Process`. Remote: + /// rounds through `ssh host hermes …`. Use this from any VM that needs + /// to fire off a CLI command — never spawn `hermes` via `Process()` + /// directly, because that path bypasses the transport for remote. + @discardableResult + nonisolated func runHermes(_ args: [String], timeout: TimeInterval = 60, stdin: String? = nil) -> (output: String, exitCode: Int32) { + let result = HermesFileService(context: self).runHermesCLI(args: args, timeout: timeout, stdinInput: stdin) + return (result.output, result.exitCode) + } + + /// Reveal the file at `path` in the user's local editor (via + /// `NSWorkspace.open`). For remote contexts this is a no-op — the + /// file doesn't exist on this Mac, so opening it would fail silently + /// or worse, open the wrong file from the local filesystem. + /// Returns `true` if opened, `false` if the call was skipped. + @discardableResult + func openInLocalEditor(_ path: String) -> Bool { + guard !isRemote else { return false } + #if canImport(AppKit) + NSWorkspace.shared.open(URL(fileURLWithPath: path)) + return true + #else + return false + #endif + } +} diff --git a/scarf/scarf/Core/Persistence/ServerRegistry.swift b/scarf/scarf/Core/Persistence/ServerRegistry.swift index 246a242..452335f 100644 --- a/scarf/scarf/Core/Persistence/ServerRegistry.swift +++ b/scarf/scarf/Core/Persistence/ServerRegistry.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Persisted entry for a user-added server. `ServerContext` itself is a value diff --git a/scarf/scarf/Core/Services/HermesEnvService.swift b/scarf/scarf/Core/Services/HermesEnvService.swift index 33dcf1b..a62b616 100644 --- a/scarf/scarf/Core/Services/HermesEnvService.swift +++ b/scarf/scarf/Core/Services/HermesEnvService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Read/write `~/.hermes/.env` while preserving comments, blank lines, and the diff --git a/scarf/scarf/Core/Services/HermesFileWatcher.swift b/scarf/scarf/Core/Services/HermesFileWatcher.swift index 7a8c78e..1f0e4aa 100644 --- a/scarf/scarf/Core/Services/HermesFileWatcher.swift +++ b/scarf/scarf/Core/Services/HermesFileWatcher.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class HermesFileWatcher { diff --git a/scarf/scarf/Core/Services/ModelCatalogService.swift b/scarf/scarf/Core/Services/ModelCatalogService.swift index 190aff5..2c05a16 100644 --- a/scarf/scarf/Core/Services/ModelCatalogService.swift +++ b/scarf/scarf/Core/Services/ModelCatalogService.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// A single model from the models.dev catalog shipped with hermes. diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift index 054244a..aa7c449 100644 --- a/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift +++ b/scarf/scarf/Features/CredentialPools/ViewModels/CredentialPoolsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift index 29500d6..089c714 100644 --- a/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift +++ b/scarf/scarf/Features/CredentialPools/ViewModels/OAuthFlowController.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift index 6449fe1..4f31b11 100644 --- a/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift +++ b/scarf/scarf/Features/CredentialPools/Views/CredentialPoolsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct CredentialPoolsView: View { @State private var viewModel: CredentialPoolsViewModel diff --git a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift index 384da0b..80c31d6 100644 --- a/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift +++ b/scarf/scarf/Features/Health/ViewModels/HealthViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore struct HealthCheck: Identifiable { let id = UUID() diff --git a/scarf/scarf/Features/Health/Views/HealthView.swift b/scarf/scarf/Features/Health/Views/HealthView.swift index a2482fd..615b0b1 100644 --- a/scarf/scarf/Features/Health/Views/HealthView.swift +++ b/scarf/scarf/Features/Health/Views/HealthView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct HealthView: View { @State private var viewModel: HealthViewModel diff --git a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift index 9af34d2..955ecca 100644 --- a/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift +++ b/scarf/scarf/Features/Logs/ViewModels/LogsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class LogsViewModel { diff --git a/scarf/scarf/Features/Logs/Views/LogsView.swift b/scarf/scarf/Features/Logs/Views/LogsView.swift index afd4dae..0bf4c43 100644 --- a/scarf/scarf/Features/Logs/Views/LogsView.swift +++ b/scarf/scarf/Features/Logs/Views/LogsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct LogsView: View { @State private var viewModel: LogsViewModel diff --git a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift index 01ef546..077e752 100644 --- a/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift +++ b/scarf/scarf/Features/Memory/ViewModels/MemoryViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore @Observable final class MemoryViewModel { diff --git a/scarf/scarf/Features/Memory/Views/MemoryView.swift b/scarf/scarf/Features/Memory/Views/MemoryView.swift index 40a34b5..8d77137 100644 --- a/scarf/scarf/Features/Memory/Views/MemoryView.swift +++ b/scarf/scarf/Features/Memory/Views/MemoryView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MemoryView: View { @State private var viewModel: MemoryViewModel diff --git a/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift index 8ac6db6..a95d655 100644 --- a/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift +++ b/scarf/scarf/Features/Personalities/ViewModels/PersonalitiesViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift index 35e3556..fe74334 100644 --- a/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift +++ b/scarf/scarf/Features/Personalities/Views/PersonalitiesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct PersonalitiesView: View { @State private var viewModel: PersonalitiesViewModel diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift index df5da2e..c9b37e2 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/EmailSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Email setup. IMAP/SMTP with app passwords — no OAuth. /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/email diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift index 9e6f182..962a640 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/FeishuSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Feishu/Lark setup. Choose domain (feishu = China, lark = international). /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/feishu diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift index c35a3ca..0cfa3b7 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/HomeAssistantSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit /// Home Assistant setup. Long-lived access token in `.env`, scalar filters via diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift index d16fb10..91ab5aa 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/IMessageSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// iMessage via BlueBubbles. Requires a BlueBubbles Server running on a Mac /// that's always on, with an Apple ID signed into Messages.app. diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift index 6a29996..a25efdb 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MatrixSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Matrix setup. Supports both access-token and password auth. No SSO. /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/matrix diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift index c1e8026..d6c228a 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/MattermostSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Mattermost setup. Server URL + personal access token (or bot token). /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/mattermost diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift index d497cf7..0475c6e 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/PlatformSetupHelpers.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift index 61c744a..ef527c7 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SignalSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Signal setup. Users must install `signal-cli` externally (needs Java), link /// their account via `signal-cli link -n ...`, and run a daemon on an HTTP port diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift index a659ed0..f3a87c4 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/SlackSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Slack setup. Requires two tokens (bot + app-level for Socket Mode). /// Field reference: https://hermes-agent.nousresearch.com/docs/user-guide/messaging/slack diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift index adecd15..7bda888 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/TelegramSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Telegram platform setup. Credentials live in `.env` (`TELEGRAM_*`); mention / diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift index 85afee9..3e54854 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WebhookSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// Webhook platform setup. Just the global enable/port/secret — per-subscription /// routes live in the Webhooks sidebar feature. diff --git a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift index ee2a1ac..2fefa51 100644 --- a/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift +++ b/scarf/scarf/Features/Platforms/ViewModels/PlatformSetup/WhatsAppSetupViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore /// WhatsApp setup. Unlike other platforms, pairing requires scanning a QR code /// via the `hermes whatsapp` CLI wizard — we expose that as an embedded diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift index 1543e85..fd386f6 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/DiscordSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct DiscordSetupView: View { @State private var viewModel: DiscordSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift index e634b6d..73a3856 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/EmailSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct EmailSetupView: View { @State private var viewModel: EmailSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift index f25151a..dc97058 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/FeishuSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct FeishuSetupView: View { @State private var viewModel: FeishuSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift index ba46ad0..05cd2ab 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/HomeAssistantSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct HomeAssistantSetupView: View { @State private var viewModel: HomeAssistantSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift index 627754b..0560632 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/IMessageSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct IMessageSetupView: View { @State private var viewModel: IMessageSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift index 004933e..273eee7 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MatrixSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MatrixSetupView: View { @State private var viewModel: MatrixSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift index b5c2383..cd97c27 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/MattermostSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct MattermostSetupView: View { @State private var viewModel: MattermostSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift index 54ba83e..5f1718f 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SignalSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SignalSetupView: View { @State private var viewModel: SignalSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift index 7310cdf..528e707 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/SlackSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SlackSetupView: View { @State private var viewModel: SlackSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift index 3db06a1..04011b7 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/TelegramSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct TelegramSetupView: View { @State private var viewModel: TelegramSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift index ea5e2c0..9b3a3f0 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WebhookSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct WebhookSetupView: View { @State private var viewModel: WebhookSetupViewModel diff --git a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift index 0356bf4..8e8797b 100644 --- a/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift +++ b/scarf/scarf/Features/Platforms/Views/PlatformSetup/WhatsAppSetupView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct WhatsAppSetupView: View { @State private var viewModel: WhatsAppSetupViewModel diff --git a/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift index ff07daa..5b84162 100644 --- a/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift +++ b/scarf/scarf/Features/Plugins/ViewModels/PluginsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os struct HermesPlugin: Identifiable, Sendable, Equatable { diff --git a/scarf/scarf/Features/Plugins/Views/PluginsView.swift b/scarf/scarf/Features/Plugins/Views/PluginsView.swift index 28e56f9..097e4ff 100644 --- a/scarf/scarf/Features/Plugins/Views/PluginsView.swift +++ b/scarf/scarf/Features/Plugins/Views/PluginsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct PluginsView: View { @State private var viewModel: PluginsViewModel diff --git a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift index 1142d2f..fb88894 100644 --- a/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift +++ b/scarf/scarf/Features/Profiles/ViewModels/ProfilesViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os struct HermesProfile: Identifiable, Sendable, Equatable { diff --git a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift index 8ad1ce9..c1134d2 100644 --- a/scarf/scarf/Features/Profiles/Views/ProfilesView.swift +++ b/scarf/scarf/Features/Profiles/Views/ProfilesView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import AppKit import UniformTypeIdentifiers diff --git a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift index 9e948a3..f01d546 100644 --- a/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift +++ b/scarf/scarf/Features/QuickCommands/ViewModels/QuickCommandsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit import os diff --git a/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift index 1a14303..ce77be8 100644 --- a/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift +++ b/scarf/scarf/Features/QuickCommands/Views/QuickCommandsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct QuickCommandsView: View { @State private var viewModel: QuickCommandsViewModel diff --git a/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift index 526def4..df0cc58 100644 --- a/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift +++ b/scarf/scarf/Features/Servers/ViewModels/AddServerViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import AppKit /// Drives the Add Server sheet. Exposed state maps 1:1 to form fields, plus diff --git a/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift index 5410a2c..8927626 100644 --- a/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift +++ b/scarf/scarf/Features/Servers/ViewModels/ConnectionStatusViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Tracks connection health for the current window's server. Remote contexts diff --git a/scarf/scarf/Features/Servers/ViewModels/RemoteDiagnosticsViewModel.swift b/scarf/scarf/Features/Servers/ViewModels/RemoteDiagnosticsViewModel.swift index 06b9649..c70578e 100644 --- a/scarf/scarf/Features/Servers/ViewModels/RemoteDiagnosticsViewModel.swift +++ b/scarf/scarf/Features/Servers/ViewModels/RemoteDiagnosticsViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os /// Runs a fixed check-list against a remote server and reports per-probe diff --git a/scarf/scarf/Features/Servers/Views/AddServerSheet.swift b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift index 2ee63e5..bc27c59 100644 --- a/scarf/scarf/Features/Servers/Views/AddServerSheet.swift +++ b/scarf/scarf/Features/Servers/Views/AddServerSheet.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore /// Sheet for adding a new remote server. Collects SSH connection details, /// runs a "Test Connection" probe, and — on save — hands the persisted diff --git a/scarf/scarf/Features/Servers/Views/ManageServersView.swift b/scarf/scarf/Features/Servers/Views/ManageServersView.swift index f702b48..78684ae 100644 --- a/scarf/scarf/Features/Servers/Views/ManageServersView.swift +++ b/scarf/scarf/Features/Servers/Views/ManageServersView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore /// List of registered remote servers with add/remove actions. Rendered as a /// popover from the toolbar switcher. diff --git a/scarf/scarf/Features/Servers/Views/MissingServerView.swift b/scarf/scarf/Features/Servers/Views/MissingServerView.swift index d6eab23..7425106 100644 --- a/scarf/scarf/Features/Servers/Views/MissingServerView.swift +++ b/scarf/scarf/Features/Servers/Views/MissingServerView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore /// Shown when a window is restored after the user removed the server it /// was bound to. Lets them open Local or any remaining registered server diff --git a/scarf/scarf/Features/Servers/Views/RemoteDiagnostics.swift b/scarf/scarf/Features/Servers/Views/RemoteDiagnostics.swift index 2aa709c..05bb161 100644 --- a/scarf/scarf/Features/Servers/Views/RemoteDiagnostics.swift +++ b/scarf/scarf/Features/Servers/Views/RemoteDiagnostics.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import AppKit /// Per-server diagnostics sheet. Shown from Manage Servers and from the diff --git a/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift b/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift index 64ed473..4befab8 100644 --- a/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift +++ b/scarf/scarf/Features/Servers/Views/ServerSwitcherToolbar.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore /// Toolbar control that shows the current window's server and exposes a /// menu for opening *other* servers in additional windows. Multi-window is diff --git a/scarf/scarf/Features/Settings/Views/SettingsView.swift b/scarf/scarf/Features/Settings/Views/SettingsView.swift index a397461..669ec16 100644 --- a/scarf/scarf/Features/Settings/Views/SettingsView.swift +++ b/scarf/scarf/Features/Settings/Views/SettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore /// Settings is now organized into tabs because the full Hermes config surface is far /// too large for a single scrolling form (~70 config fields). Each tab has its own diff --git a/scarf/scarf/Features/Skills/Views/SkillsView.swift b/scarf/scarf/Features/Skills/Views/SkillsView.swift index 0ec9834..b29e065 100644 --- a/scarf/scarf/Features/Skills/Views/SkillsView.swift +++ b/scarf/scarf/Features/Skills/Views/SkillsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore struct SkillsView: View { @State private var viewModel: SkillsViewModel diff --git a/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift index 19e1446..961318c 100644 --- a/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift +++ b/scarf/scarf/Features/Webhooks/ViewModels/WebhooksViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import ScarfCore import os struct HermesWebhook: Identifiable, Sendable, Equatable { diff --git a/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift index f6580f7..3355136 100644 --- a/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift +++ b/scarf/scarf/Features/Webhooks/Views/WebhooksView.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore import AppKit struct WebhooksView: View { diff --git a/scarf/scarf/scarfApp.swift b/scarf/scarf/scarfApp.swift index ea0836f..1f23557 100644 --- a/scarf/scarf/scarfApp.swift +++ b/scarf/scarf/scarfApp.swift @@ -1,4 +1,5 @@ import SwiftUI +import ScarfCore @main struct ScarfApp: App { diff --git a/scarf/scarfTests/scarfTests.swift b/scarf/scarfTests/scarfTests.swift index b013021..ab10b25 100644 --- a/scarf/scarfTests/scarfTests.swift +++ b/scarf/scarfTests/scarfTests.swift @@ -7,6 +7,7 @@ import Testing import Darwin +import ScarfCore @testable import scarf @Suite struct ControlPathTests {