diff --git a/Sources/LLM/CommandAssistant.swift b/Sources/LLM/CommandAssistant.swift index fa49f12..e15a030 100644 --- a/Sources/LLM/CommandAssistant.swift +++ b/Sources/LLM/CommandAssistant.swift @@ -71,7 +71,7 @@ enum CommandSanitizer { var inside = false var collected: [String] = [] for line in lines { - if line.trimmingCharacters(in: .whitespaces).hasPrefix("```") { + if line.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") { if inside { return collected.joined(separator: "\n") } // closing fence inside = true continue @@ -85,18 +85,28 @@ enum CommandSanitizer { let lines = text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) .map(stripLine) - .filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } return lines.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines) } private static func stripLine(_ line: String) -> String { - var s = line.trimmingCharacters(in: .whitespaces) + var s = line.trimmingCharacters(in: .whitespacesAndNewlines) s = s.trimmingCharacters(in: CharacterSet(charactersIn: "`")) + s = unwrapSurroundingQuotes(s) for prefix in ["$ ", "# ", "% "] where s.hasPrefix(prefix) { s = String(s.dropFirst(prefix.count)) break } - return s + return unwrapSurroundingQuotes(s) + } + + /// Unwrap one matching pair of quotes around the whole line. + static func unwrapSurroundingQuotes(_ text: String) -> String { + guard text.count >= 2, let first = text.first, let last = text.last else { return text } + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + return String(text.dropFirst().dropLast()) + } + return text } } diff --git a/Sources/LLM/Completion.swift b/Sources/LLM/Completion.swift index c8a6fef..13a83d1 100644 --- a/Sources/LLM/Completion.swift +++ b/Sources/LLM/Completion.swift @@ -64,6 +64,7 @@ enum CompletionSanitizer { line = trimTrailingWhitespace(line) line = line.trimmingCharacters(in: CharacterSet(charactersIn: "`")) + line = unwrapSurroundingQuotes(line) line = trimTrailingWhitespace(line) // Preferred contract: the model echoes the full command line, so we @@ -72,28 +73,43 @@ enum CompletionSanitizer { if line.hasPrefix(currentLine) { return String(line.dropFirst(currentLine.count)) } - // Fallback: the model returned just the suffix. - return line.trimmingCharacters(in: .whitespaces) + // Some models pad the echoed line with a leading space. + let stripped = String(line.drop(while: { $0 == " " || $0 == "\t" })) + if stripped.hasPrefix(currentLine) { + return String(stripped.dropFirst(currentLine.count)) + } + // Fallback: the model returned just the suffix. Keep a leading space so + // currentLine "git" + suffix " status" does not become "gitstatus". + return trimTrailingWhitespace(line) } private static func stripFenceLines(_ text: String) -> String { text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) - .filter { !$0.trimmingCharacters(in: .whitespaces).hasPrefix("```") } + .filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix("```") } .joined(separator: "\n") } private static func firstNonBlankLine(_ text: String) -> String? { text.split(separator: "\n", omittingEmptySubsequences: false) .map(String.init) - .first { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + .first { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } private static func trimTrailingWhitespace(_ text: String) -> String { var result = text - while let last = result.last, last == " " || last == "\t" { result.removeLast() } + while let last = result.last, last.isWhitespace { result.removeLast() } return result } + + /// Unwrap one matching pair of quotes around the whole line. + static func unwrapSurroundingQuotes(_ text: String) -> String { + guard text.count >= 2, let first = text.first, let last = text.last else { return text } + if (first == "\"" && last == "\"") || (first == "'" && last == "'") { + return String(text.dropFirst().dropLast()) + } + return text + } } /// Decides whether a completion request is worth making for the current line. diff --git a/Sources/LLM/LLMProvider.swift b/Sources/LLM/LLMProvider.swift index 34b320e..9551d78 100644 --- a/Sources/LLM/LLMProvider.swift +++ b/Sources/LLM/LLMProvider.swift @@ -97,7 +97,7 @@ final class HTTPProviderClient: LLMProviderClient { } @MainActor private func streamOpenAIChat(request: LLMRequest, onDelta: (@MainActor (String) -> Void)?) async throws -> LLMResponse { - let client = OpenAI(configuration: openAIConfiguration()) + let client = OpenAI(configuration: try openAIConfiguration()) guard let systemMessage = ChatQuery.ChatCompletionMessageParam(role: .system, content: request.systemPrompt), let userMessage = ChatQuery.ChatCompletionMessageParam(role: .user, content: request.userPrompt) else { throw LLMProviderError.malformedJSON @@ -196,8 +196,8 @@ final class HTTPProviderClient: LLMProviderClient { return try finalize(content: content, reasoning: reasoning, model: model, usage: usage) } - private func openAIConfiguration() -> OpenAI.Configuration { - let (scheme, host, port, basePath) = ProviderURLSplitter.split(baseURL: profile.baseURL, endpoint: profile.chatEndpoint) + private func openAIConfiguration() throws -> OpenAI.Configuration { + let (scheme, host, port, basePath) = try ProviderURLSplitter.split(baseURL: profile.baseURL, endpoint: profile.chatEndpoint) return OpenAI.Configuration( token: apiKey, host: host, @@ -255,12 +255,22 @@ final class HTTPProviderClient: LLMProviderClient { /// Splits a base URL + endpoint into the components the MacPaw OpenAI SDK needs. /// Ported from runse so the streaming URL is built identically. Pure/testable. enum ProviderURLSplitter { - static func split(baseURL: String, endpoint: String) -> (scheme: String, host: String, port: Int, basePath: String) { - let url = URL(string: baseURL) - let scheme = url?.scheme ?? "https" - let host = url?.host ?? "api.openai.com" - let port = url?.port ?? (scheme == "https" ? 443 : 80) - let path = url?.path ?? "" + static func split(baseURL: String, endpoint: String) throws -> (scheme: String, host: String, port: Int, basePath: String) { + let trimmed = baseURL.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { throw LLMProviderError.invalidURL } + // Accept "host[:port][/path]" without a scheme; never guess a vendor host. + let normalized = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + guard let url = URL(string: normalized), + let host = url.host, + !host.isEmpty else { + throw LLMProviderError.invalidURL + } + let scheme = (url.scheme ?? "https").lowercased() + guard scheme == "https" || scheme == "http" else { + throw LLMProviderError.invalidURL + } + let port = url.port ?? (scheme == "https" ? 443 : 80) + let path = url.path // MacPaw/OpenAI tacks "/chat/completions" onto basePath itself, so we // strip that suffix if the profile's endpoint includes it. let endpointBase: String diff --git a/Sources/Model/CommandHistory.swift b/Sources/Model/CommandHistory.swift index 923ad88..f7eb4b2 100644 --- a/Sources/Model/CommandHistory.swift +++ b/Sources/Model/CommandHistory.swift @@ -6,34 +6,37 @@ import Foundation /// This is a best-effort *local* mirror of what the user types — it does not /// read the remote shell's own history. Entries are capped, deduplicated /// (most-recent first), and persisted in UserDefaults so suggestions survive -/// app restarts. Stored as plain text; callers should avoid recording lines -/// that are obviously secrets (handled at the call site). +/// app restarts. Stored as plain text; `CommandHistoryPolicy` drops obvious +/// secret-bearing lines before they are saved. final class CommandHistory { static let shared = CommandHistory() private let defaultsKey = "commandHistory" + private let defaults: UserDefaults private let maxEntries = 200 - private let minLength = 2 private var entries: [String] = [] // most-recent first - init() { load() } + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + load() + } private func load() { - if let saved = UserDefaults.standard.stringArray(forKey: defaultsKey) { + if let saved = defaults.stringArray(forKey: defaultsKey) { entries = saved } } private func persist() { - UserDefaults.standard.set(entries, forKey: defaultsKey) + defaults.set(entries, forKey: defaultsKey) } - /// Record a completed command line. Blank/too-short lines are ignored; - /// duplicates move to the front so recent commands rank highest. + /// Record a completed command line. Blank/too-short/secret-looking lines + /// are ignored; duplicates move to the front so recent commands rank highest. func record(_ line: String) { + guard CommandHistoryPolicy.shouldRecord(line) else { return } let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.count >= minLength else { return } entries.removeAll { $0 == trimmed } entries.insert(trimmed, at: 0) if entries.count > maxEntries { @@ -65,3 +68,33 @@ final class CommandHistory { persist() } } + +/// Decides whether a committed line is safe to persist as shell history. +enum CommandHistoryPolicy { + static let minLength = 2 + + static func shouldRecord(_ line: String) -> Bool { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count >= minLength else { return false } + let lower = trimmed.lowercased() + if lower == "passwd" || lower.hasPrefix("passwd ") { return false } + if lower.hasPrefix("sshpass ") { return false } + return !containsSecretAssignment(trimmed) + } + + /// True when a whitespace-delimited token looks like `NAME=value` and NAME + /// is a password/secret/token/api-key style identifier. + private static func containsSecretAssignment(_ line: String) -> Bool { + for token in line.split(whereSeparator: { $0.isWhitespace }) { + guard let eq = token.firstIndex(of: "=") else { continue } + let name = token[.. Void) { + guard !fired else { return } + fired = true + handler(error) + } +} diff --git a/Sources/SSH/KnownHosts.swift b/Sources/SSH/KnownHosts.swift index 5afc56a..de286e5 100644 --- a/Sources/SSH/KnownHosts.swift +++ b/Sources/SSH/KnownHosts.swift @@ -32,30 +32,43 @@ struct HostKeyPrompt { enum SSHFingerprint { static func sha256(ofOpenSSH openSSH: String) -> String { let fields = openSSH.split(separator: " ", omittingEmptySubsequences: true) - guard fields.count >= 2, let blob = Data(base64Encoded: String(fields[1])) else { + guard fields.count >= 2, let blob = decodeKeyBlob(String(fields[1])) else { return "SHA256:" } let digest = SHA256.hash(data: blob) let b64 = Data(digest).base64EncodedString().replacingOccurrences(of: "=", with: "") return "SHA256:" + b64 } + + /// OpenSSH key blobs are standard base64; some encodings omit padding, which + /// `Data(base64Encoded:)` rejects unless we restore it. + static func decodeKeyBlob(_ b64: String) -> Data? { + var padded = b64.trimmingCharacters(in: .whitespacesAndNewlines) + let remainder = padded.count % 4 + if remainder != 0 { + padded += String(repeating: "=", count: 4 - remainder) + } + return Data(base64Encoded: padded, options: .ignoreUnknownCharacters) + } } /// Persists accepted host keys (OpenSSH public-key strings) keyed by host:port, /// for trust-on-first-use verification. final class KnownHostsStore { private let defaultsKey = "knownHosts" + private let defaults: UserDefaults private var map: [String: String] - init() { - map = (UserDefaults.standard.dictionary(forKey: defaultsKey) as? [String: String]) ?? [:] + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + map = (defaults.dictionary(forKey: defaultsKey) as? [String: String]) ?? [:] } func key(for hostID: String) -> String? { map[hostID] } func remember(_ key: String, for hostID: String) { map[hostID] = key - UserDefaults.standard.set(map, forKey: defaultsKey) + defaults.set(map, forKey: defaultsKey) } } diff --git a/Sources/SSH/PTYChannelHandler.swift b/Sources/SSH/PTYChannelHandler.swift index 4e784f8..7198e44 100644 --- a/Sources/SSH/PTYChannelHandler.swift +++ b/Sources/SSH/PTYChannelHandler.swift @@ -23,6 +23,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { private let onClose: (Error?) -> Void private var context: ChannelHandlerContext? + private var closeOnce = ChannelCloseOnce() init( term: String, @@ -66,7 +67,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func channelInactive(context: ChannelHandlerContext) { - onClose(nil) + closeOnce.deliver(nil, to: onClose) context.fireChannelInactive() } @@ -79,7 +80,7 @@ final class PTYChannelHandler: ChannelDuplexHandler { } func errorCaught(context: ChannelHandlerContext, error: Error) { - onClose(error) + closeOnce.deliver(error, to: onClose) context.close(promise: nil) } diff --git a/Sources/SSH/PortForwardManager.swift b/Sources/SSH/PortForwardManager.swift index 0971502..c4c15f7 100644 --- a/Sources/SSH/PortForwardManager.swift +++ b/Sources/SSH/PortForwardManager.swift @@ -57,6 +57,10 @@ final class PortForwardManager { /// off->on toggle would otherwise fail with EADDRINUSE. private var closing: [UUID: EventLoopFuture] = [:] + /// Set by `stopAll()`. An in-flight bind that completes afterwards must + /// close the new listener instead of putting it back into `listeners`. + private var tearingDown = false + private let log = Logger(subsystem: "io.github.madeye.gterm", category: "PortForward") init(parentChannel: Channel, @@ -78,6 +82,7 @@ final class PortForwardManager { func start(_ forward: PortForward) { group.next().execute { [weak self] in guard let self else { return } + if self.tearingDown { return } let id = forward.id // Idempotent: already listening, or a bind is already in flight. if self.listeners[id] != nil || self.starting.contains(id) { return } @@ -96,6 +101,10 @@ final class PortForwardManager { /// already contains the id (cleared here when the bind settles). private func performBind(_ forward: PortForward) { let id = forward.id + if tearingDown { + starting.remove(id) + return + } let bootstrap = ServerBootstrap(group: self.group) .serverChannelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) .serverChannelOption(ChannelOptions.backlog, value: 16) @@ -111,6 +120,12 @@ final class PortForwardManager { bootstrap.bind(host: "127.0.0.1", port: forward.localPort).whenComplete { [weak self] result in guard let self else { return } self.starting.remove(id) + if self.tearingDown { + if case .success(let serverChannel) = result { + serverChannel.close(promise: nil) + } + return + } switch result { case .success(let serverChannel): self.listeners[id] = serverChannel @@ -154,6 +169,7 @@ final class PortForwardManager { let promise = loop.makePromise(of: Void.self) loop.execute { [weak self] in guard let self else { return promise.succeed(()) } + self.tearingDown = true var closes: [EventLoopFuture] = [] for ch in self.listeners.values { closes.append(ch.close().recover { _ in () }) } self.listeners.removeAll() diff --git a/Sources/SSH/SSHKeyParser.swift b/Sources/SSH/SSHKeyParser.swift index 1a608e9..31dfb16 100644 --- a/Sources/SSH/SSHKeyParser.swift +++ b/Sources/SSH/SSHKeyParser.swift @@ -46,7 +46,7 @@ enum SSHKeyParser { throw SSHKeyError.encrypted } if trimmed.contains("BEGIN EC PRIVATE KEY") || trimmed.contains("BEGIN PRIVATE KEY") { - return try parsePEMECDSA(text) + return try parsePEMECDSA(trimmed) } if trimmed.contains("PUBLIC KEY") || trimmed.hasPrefix("ssh-") || trimmed.hasPrefix("ecdsa-") { throw SSHKeyError.notAPrivateKey @@ -54,6 +54,24 @@ enum SSHKeyParser { throw SSHKeyError.malformed("unrecognized format") } + /// Parse every non-empty key blob, skipping ones that fail. The first + /// parse error is returned so the caller can surface it when *nothing* + /// usable remains (no valid key and no password). + static func parseUsable(_ texts: [String]) -> (keys: [ParsedKey], firstError: Error?) { + var keys: [ParsedKey] = [] + var firstError: Error? + for text in texts { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + do { + keys.append(try parse(trimmed)) + } catch { + if firstError == nil { firstError = error } + } + } + return (keys, firstError) + } + private static func friendlyType(_ sshName: String) -> String { switch sshName { case "ssh-ed25519": return "Ed25519" diff --git a/Sources/SSH/SSHPort.swift b/Sources/SSH/SSHPort.swift new file mode 100644 index 0000000..5011a39 --- /dev/null +++ b/Sources/SSH/SSHPort.swift @@ -0,0 +1,10 @@ +import Foundation + +/// TCP port numbers accepted for SSH connections and similar UI fields. +enum SSHPort { + static func parse(_ text: String) -> Int? { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = Int(trimmed), (1...65535).contains(value) else { return nil } + return value + } +} diff --git a/Sources/SSH/SSHSession.swift b/Sources/SSH/SSHSession.swift index cd15fa0..0adb104 100644 --- a/Sources/SSH/SSHSession.swift +++ b/Sources/SSH/SSHSession.swift @@ -19,16 +19,6 @@ struct SSHConnection: Identifiable { var savedID: UUID? = nil } -/// High-level lifecycle of an SSH session, surfaced to the UI. -enum SSHSessionState: Equatable { - case idle - case connecting - case authenticating - case connected - case failed(String) - case closed -} - /// Drives an interactive SSH shell over a PTY using swift-nio-ssh and feeds it /// into a TerminalSurfaceView. It is the surface's delegate: user input flows /// out to the channel, server output flows into the surface, and resizes are @@ -36,7 +26,7 @@ enum SSHSessionState: Equatable { final class SSHSession: TerminalSession { private let connection: SSHConnection private weak var view: TerminalSurfaceView? - private let onStateChange: (SSHSessionState) -> Void + private let lifecycle: SessionStateNotifier private let onHostKeyPrompt: TOFUHostKeyDelegate.Prompt? private let group: EventLoopGroup @@ -61,32 +51,29 @@ final class SSHSession: TerminalSession { self.forwards = forwards self.onHostKeyPrompt = onHostKeyPrompt self.onForwardChange = onForwardChange - self.onStateChange = onStateChange + self.lifecycle = SessionStateNotifier(onStateChange: onStateChange) self.group = MultiThreadedEventLoopGroup(numberOfThreads: 1) } // MARK: TerminalSession func start() { - notify(.connecting) + lifecycle.setStopped(false) + lifecycle.notify(.connecting) // Build authentication offers in preference order: private key (if - // provided), then password (if provided). + // provided), then password (if provided). Skip keys that fail to parse + // so one RSA/encrypted key does not block a good key or password. var offers: [NIOSSHUserAuthenticationOffer.Offer] = [] - for keyText in connection.privateKeys where !keyText.isEmpty { - do { - let parsed = try SSHKeyParser.parse(keyText) - offers.append(.privateKey(.init(privateKey: parsed.key))) - } catch { - notify(.failed(Self.describe(error))) - return - } + let parsed = SSHKeyParser.parseUsable(connection.privateKeys) + for item in parsed.keys { + offers.append(.privateKey(.init(privateKey: item.key))) } if !connection.password.isEmpty { offers.append(.password(.init(password: connection.password))) } guard !offers.isEmpty else { - notify(.failed("No authentication method provided.")) + lifecycle.notify(.failed(parsed.firstError.map(Self.describe) ?? "No authentication method provided.")) return } @@ -98,6 +85,7 @@ final class SSHSession: TerminalSession { let bootstrap = ClientBootstrap(group: group) .channelOption(ChannelOptions.socketOption(.so_reuseaddr), value: 1) + .channelOption(ChannelOptions.connectTimeout, value: .seconds(20)) .channelInitializer { channel in let sshHandler = NIOSSHHandler( role: .client(.init( @@ -110,12 +98,16 @@ final class SSHSession: TerminalSession { return channel.pipeline.addHandler(sshHandler) } - notify(.authenticating) + lifecycle.notify(.authenticating) bootstrap.connect(host: connection.host, port: connection.port).whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + if case .success(let channel) = result { channel.close(promise: nil) } + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let channel): self.channel = channel self.openShellChannel(on: channel) @@ -124,6 +116,7 @@ final class SSHSession: TerminalSession { } func stop() { + lifecycle.setStopped(true) let group = self.group // Close port-forward listeners + tunnels FIRST and wait for them to be // fully released, THEN close the channels and shut the group down. Shutting @@ -146,13 +139,21 @@ final class SSHSession: TerminalSession { // MARK: Open the PTY shell child channel private func openShellChannel(on channel: Channel) { + if lifecycle.isStopped() { + channel.close(promise: nil) + return + } let (cols, rows) = view?.gridSize ?? (80, 24) channel.pipeline.handler(type: NIOSSHHandler.self).whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + channel.close(promise: nil) + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let sshHandler): let promise = channel.eventLoop.makePromise(of: Channel.self) @@ -177,9 +178,13 @@ final class SSHSession: TerminalSession { promise.futureResult.whenComplete { [weak self] result in guard let self else { return } + if self.lifecycle.isStopped() { + if case .success(let child) = result { child.close(promise: nil) } + return + } switch result { case .failure(let error): - self.notify(.failed(Self.describe(error))) + self.lifecycle.notify(.failed(Self.describe(error))) case .success(let childChannel): self.childChannel = childChannel // The parent `channel` is the authenticated connection; @@ -191,7 +196,7 @@ final class SSHSession: TerminalSession { ) self.forwardManager = mgr for f in self.forwards where f.autoStart { mgr.start(f) } - self.notify(.connected) + self.lifecycle.notify(.connected) } } } @@ -221,9 +226,9 @@ final class SSHSession: TerminalSession { private func handleChannelClose(_ error: Error?) { if let error { - notify(.failed(Self.describe(error))) + lifecycle.notify(.failed(Self.describe(error))) } else { - notify(.closed) + lifecycle.notify(.closed) } } @@ -247,10 +252,6 @@ final class SSHSession: TerminalSession { // MARK: Helpers - private func notify(_ state: SSHSessionState) { - DispatchQueue.main.async { [onStateChange] in onStateChange(state) } - } - private static func describe(_ error: Error) -> String { if let keyError = error as? SSHKeyError { return keyError.description diff --git a/Sources/SSH/SessionStateNotifier.swift b/Sources/SSH/SessionStateNotifier.swift new file mode 100644 index 0000000..cd996f6 --- /dev/null +++ b/Sources/SSH/SessionStateNotifier.swift @@ -0,0 +1,47 @@ +import Foundation + +/// High-level lifecycle of an SSH session, surfaced to the UI. +enum SSHSessionState: Equatable { + case idle + case connecting + case authenticating + case connected + case failed(String) + case closed +} + +/// Delivers session state to the UI on the main queue. +/// +/// `stop()` can race a `notify` that has already decided to hop: if the +/// callback ran without re-checking, a queued `.connected` would overwrite +/// `ActiveSession.state` after disconnect, and the subsequent +/// `handleChannelClose` would be dropped because the session is stopped. +/// The hop always re-reads `isStopped` on the main queue. +final class SessionStateNotifier { + private let lock = NSLock() + private var stopped = false + private let onStateChange: (SSHSessionState) -> Void + + init(onStateChange: @escaping (SSHSessionState) -> Void) { + self.onStateChange = onStateChange + } + + func setStopped(_ value: Bool) { + lock.lock() + stopped = value + lock.unlock() + } + + func isStopped() -> Bool { + lock.lock() + defer { lock.unlock() } + return stopped + } + + func notify(_ state: SSHSessionState) { + DispatchQueue.main.async { [weak self] in + guard let self, !self.isStopped() else { return } + self.onStateChange(state) + } + } +} diff --git a/Sources/UI/AddConnectionView.swift b/Sources/UI/AddConnectionView.swift index e663919..60ae644 100644 --- a/Sources/UI/AddConnectionView.swift +++ b/Sources/UI/AddConnectionView.swift @@ -25,7 +25,7 @@ struct AddConnectionView: View { private var isValid: Bool { !connection.host.trimmingCharacters(in: .whitespaces).isEmpty && !connection.username.trimmingCharacters(in: .whitespaces).isEmpty && - Int(portText) != nil + SSHPort.parse(portText) != nil } var body: some View { @@ -133,7 +133,7 @@ struct AddConnectionView: View { private func save() { connection.host = connection.host.trimmingCharacters(in: .whitespaces) connection.username = connection.username.trimmingCharacters(in: .whitespaces) - connection.port = Int(portText) ?? 22 + connection.port = SSHPort.parse(portText) ?? 22 store.save(connection, password: password) dismiss() } diff --git a/Tests/ChannelCloseOnceTests.swift b/Tests/ChannelCloseOnceTests.swift new file mode 100644 index 0000000..9536826 --- /dev/null +++ b/Tests/ChannelCloseOnceTests.swift @@ -0,0 +1,29 @@ +import XCTest + +private struct DummyError: Error {} + +final class ChannelCloseOnceTests: XCTestCase { + func testErrorThenInactiveKeepsTheError() { + var once = ChannelCloseOnce() + var seen: [String] = [] + once.deliver(DummyError()) { error in + seen.append(error == nil ? "nil" : "err") + } + once.deliver(nil) { error in + seen.append(error == nil ? "nil" : "err") + } + XCTAssertEqual(seen, ["err"]) + } + + func testCleanCloseThenErrorStaysClosed() { + var once = ChannelCloseOnce() + var seen: [String] = [] + once.deliver(nil) { error in + seen.append(error == nil ? "nil" : "err") + } + once.deliver(DummyError()) { error in + seen.append(error == nil ? "nil" : "err") + } + XCTAssertEqual(seen, ["nil"]) + } +} diff --git a/Tests/CommandAssistantTests.swift b/Tests/CommandAssistantTests.swift index 91583e0..af547f2 100644 --- a/Tests/CommandAssistantTests.swift +++ b/Tests/CommandAssistantTests.swift @@ -60,6 +60,23 @@ final class CommandSanitizerTests: XCTestCase { func testEmptyForBlank() { XCTAssertEqual(CommandSanitizer.command(fromOutput: " \n\n "), "") } + + func testStripsCRLF() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "tmux new-window\r\n"), "tmux new-window") + } + + func testUnwrapsSurroundingQuotes() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "\"tmux new-window\""), "tmux new-window") + XCTAssertEqual(CommandSanitizer.command(fromOutput: "'df -h'"), "df -h") + } + + func testQuotedPromptLine() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "\"$ ls -la\""), "ls -la") + } + + func testKeepsInternalQuotes() { + XCTAssertEqual(CommandSanitizer.command(fromOutput: "echo \"hello\""), "echo \"hello\"") + } } final class QuickCommandsTests: XCTestCase { diff --git a/Tests/CommandHistoryTests.swift b/Tests/CommandHistoryTests.swift new file mode 100644 index 0000000..9f0469e --- /dev/null +++ b/Tests/CommandHistoryTests.swift @@ -0,0 +1,66 @@ +import XCTest + +final class CommandHistoryPolicyTests: XCTestCase { + func testAcceptsOrdinaryCommands() { + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("ls -la")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("git status")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("cd /tmp")) + } + + func testRejectsBlankAndShort() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord(" ")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("x")) + } + + func testRejectsPasswdAndSshpass() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("passwd")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("passwd alice")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("sshpass -p secret ssh host")) + } + + func testRejectsSecretAssignments() { + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("export AWS_SECRET_ACCESS_KEY=abc")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("PASSWORD=hunter2")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("MY_API_KEY=xyz")) + XCTAssertFalse(CommandHistoryPolicy.shouldRecord("token=abcd")) + } + + func testKeepsAssignmentsThatAreNotSecrets() { + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("FOO=bar make test")) + XCTAssertTrue(CommandHistoryPolicy.shouldRecord("echo password is not an assignment")) + } +} + +final class CommandHistoryTests: XCTestCase { + private func makeHistory() -> (CommandHistory, String, UserDefaults) { + let suite = "gterm.tests.commandHistory.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return (CommandHistory(defaults: defaults), suite, defaults) + } + + func testRecordSkipsSecretsAndStoresCommands() { + let (history, suite, defaults) = makeHistory() + defer { defaults.removePersistentDomain(forName: suite) } + + history.record("ls -la") + history.record("passwd") + history.record("export TOKEN=shh") + history.record("git status") + + XCTAssertEqual(history.recent(), ["git status", "ls -la"]) + XCTAssertEqual(history.suggestions(prefix: "git"), ["git status"]) + XCTAssertEqual(history.suggestions(prefix: "pass"), []) + } + + func testDedupMovesToFront() { + let (history, suite, defaults) = makeHistory() + defer { defaults.removePersistentDomain(forName: suite) } + + history.record("ls") + history.record("cd /tmp") + history.record("ls") + XCTAssertEqual(history.recent(), ["ls", "cd /tmp"]) + } +} diff --git a/Tests/CompletionTests.swift b/Tests/CompletionTests.swift index 5e382d8..b266520 100644 --- a/Tests/CompletionTests.swift +++ b/Tests/CompletionTests.swift @@ -73,6 +73,24 @@ final class CompletionSanitizerTests: XCTestCase { // currentLine "git" + full "git status" -> suffix " status" (with space). XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "git status", currentLine: "git"), " status") } + + func testFallbackSuffixKeepsLeadingSpace() { + // Model returned only the suffix, including the separator space. + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: " status", currentLine: "git"), " status") + } + + func testStripsCRLFBeforeMatchingPrefix() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "git status\r\n", currentLine: "git st"), "atus") + } + + func testUnwrapsSurroundingQuotes() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "\"git status\"", currentLine: "git st"), "atus") + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: "'git status'", currentLine: "git st"), "atus") + } + + func testPaddedFullLineStillYieldsSuffix() { + XCTAssertEqual(CompletionSanitizer.suffix(fromOutput: " git status", currentLine: "git st"), "atus") + } } final class CompletionPolicyTests: XCTestCase { diff --git a/Tests/KnownHostsTests.swift b/Tests/KnownHostsTests.swift new file mode 100644 index 0000000..9546e68 --- /dev/null +++ b/Tests/KnownHostsTests.swift @@ -0,0 +1,47 @@ +import XCTest + +final class SSHFingerprintTests: XCTestCase { + /// Public half of the throwaway Ed25519 fixture in SSHKeyParserTests. + private let ed25519Pub = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT gterm-test" + + /// `ssh-keygen -l -E sha256` of that key. + private let ed25519Fingerprint = "SHA256:U0RqZRgE0EEoGl1k8PBOeB9pKKmxdKJsZp/ymIsMdZU" + + private let ecdsaPub = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOofz14uFVl80GLndS/x5F7R+3HcQBC5SIqH31Ns4OT8Eu/z3Xb6n1XiHrTEwq9c6TuEPD2KqhcrgT1rj37hYxg= gterm-ecdsa" + private let ecdsaFingerprint = "SHA256:Or70brQY3Wn6ZyBb6B++Z5hKXLGGwwxWnyS68moCQcE" + + func testFingerprintMatchesOpenSSHAndUnpaddedBlob() { + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: ed25519Pub), ed25519Fingerprint) + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: ecdsaPub), ecdsaFingerprint) + + // ECDSA blobs include `=` padding. Stripping it used to make + // `Data(base64Encoded:)` fail and report SHA256:. + let fields = ecdsaPub.split(separator: " ") + XCTAssertTrue(fields[1].contains("="), "fixture must include padding so the unpadded path is exercised") + let unpadded = "\(fields[0]) \(String(fields[1]).replacingOccurrences(of: "=", with: "")) \(fields[2])" + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: unpadded), ecdsaFingerprint) + } + + func testUnknownWhenBlobMissing() { + XCTAssertEqual(SSHFingerprint.sha256(ofOpenSSH: "ssh-ed25519"), "SHA256:") + } +} + +final class KnownHostsStoreTests: XCTestCase { + func testRememberAndLookup() { + let suite = "gterm.tests.knownHosts.\(UUID().uuidString)" + guard let defaults = UserDefaults(suiteName: suite) else { + return XCTFail("could not create suite") + } + defaults.removePersistentDomain(forName: suite) + let store = KnownHostsStore(defaults: defaults) + let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT" + store.remember(key, for: "example.com:22") + XCTAssertEqual(store.key(for: "example.com:22"), key) + XCTAssertNil(store.key(for: "other:22")) + + let reloaded = KnownHostsStore(defaults: defaults) + XCTAssertEqual(reloaded.key(for: "example.com:22"), key) + defaults.removePersistentDomain(forName: suite) + } +} diff --git a/Tests/ProviderRequestTests.swift b/Tests/ProviderRequestTests.swift index 6e5aa17..125f8d4 100644 --- a/Tests/ProviderRequestTests.swift +++ b/Tests/ProviderRequestTests.swift @@ -35,8 +35,8 @@ final class ProviderRequestTests: XCTestCase { /// Covers the streaming URL construction the OpenAI SDK consumes (the runse /// `ProviderURLSplitter`). final class ProviderURLSplitterTests: XCTestCase { - func testSplitsStandardOpenAIBase() { - let (scheme, host, port, basePath) = ProviderURLSplitter.split( + func testSplitsStandardOpenAIBase() throws { + let (scheme, host, port, basePath) = try ProviderURLSplitter.split( baseURL: "https://api.openai.com", endpoint: "/v1/chat/completions" ) XCTAssertEqual(scheme, "https") @@ -46,23 +46,44 @@ final class ProviderURLSplitterTests: XCTestCase { XCTAssertEqual(basePath, "/v1") } - func testSplitsChinaProviderWithPathPrefix() { + func testSplitsChinaProviderWithPathPrefix() throws { // GLM keeps its "/api/paas/v4" prefix once "/chat/completions" is stripped. - let (_, host, _, basePath) = ProviderURLSplitter.split( + let (_, host, _, basePath) = try ProviderURLSplitter.split( baseURL: "https://open.bigmodel.cn/api/paas/v4", endpoint: "/chat/completions" ) XCTAssertEqual(host, "open.bigmodel.cn") XCTAssertEqual(basePath, "/api/paas/v4") } - func testSplitsDeepSeekBareBase() { - let (_, host, _, basePath) = ProviderURLSplitter.split( + func testSplitsDeepSeekBareBase() throws { + let (_, host, _, basePath) = try ProviderURLSplitter.split( baseURL: "https://api.deepseek.com", endpoint: "/chat/completions" ) XCTAssertEqual(host, "api.deepseek.com") XCTAssertEqual(basePath, "") } + func testHostWithoutSchemeDefaultsToHTTPS() throws { + let (scheme, host, port, _) = try ProviderURLSplitter.split( + baseURL: "api.example.com", endpoint: "/v1/chat/completions" + ) + XCTAssertEqual(scheme, "https") + XCTAssertEqual(host, "api.example.com") + XCTAssertEqual(port, 443) + } + + func testInvalidURLDoesNotFallBackToOpenAI() { + let bad = ["", "https://", " ", "not a url", "ftp://api.example.com"] + for base in bad { + XCTAssertThrowsError( + try ProviderURLSplitter.split(baseURL: base, endpoint: "/v1/chat/completions"), + "expected invalidURL for \(base)" + ) { error in + XCTAssertEqual(error as? LLMProviderError, .invalidURL) + } + } + } + func testExtraHeadersParsing() { XCTAssertEqual(ProviderURLSplitter.extraHeaders(#"{"X-Test":"yes"}"#), ["X-Test": "yes"]) XCTAssertEqual(ProviderURLSplitter.extraHeaders("not json"), [:]) diff --git a/Tests/SSHKeyParserTests.swift b/Tests/SSHKeyParserTests.swift new file mode 100644 index 0000000..5f0a319 --- /dev/null +++ b/Tests/SSHKeyParserTests.swift @@ -0,0 +1,118 @@ +import XCTest +import Crypto + +final class SSHKeyParserTests: XCTestCase { + /// Throwaway Ed25519 OpenSSH key generated for this test; never used for auth. + private let ed25519OpenSSH = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW + QyNTUxOQAAACBZHk1Mmj8CN6vhzOqitLP8J1QcpfdpW/bQiNUxVxtbEwAAAJD5i5Nx+YuT + cQAAAAtzc2gtZWQyNTUxOQAAACBZHk1Mmj8CN6vhzOqitLP8J1QcpfdpW/bQiNUxVxtbEw + AAAECIjXJdhOXvBQSWN+Sqwz/BlSf7I/TX+18zUiqBe/s2TlkeTUyaPwI3q+HM6qK0s/wn + VByl92lb9tCI1TFXG1sTAAAACmd0ZXJtLXRlc3QBAgM= + -----END OPENSSH PRIVATE KEY----- + """ + + /// Same algorithm, passphrase-protected (cipher is aes256-ctr, not "none"). + private let ed25519Encrypted = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABCrIkFwQm + hXWHjae1Qp+2p2AAAAGAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIMyWguNQ0g3osSDR + XQU2rjiEjlt6AXm90mkkBMvxgMhXAAAAkPTlcygrVo7VOKt7LDwfo9oglQYRTWcslEhn/2 + C7h+6ZU5X3VaD5214RAf+xO/gfsdcdhY4+gTey1nhgB+gtjmpyJC1FC7DNtiJxECr7gIQq + DINVSBE90wpthbbRU0R4jLmb9FjdzJPQ6tiq6mElm3/tp7wh7FJsDjRPDzcxqim9b2yGEO + /cFjQcYTsjymgDOg== + -----END OPENSSH PRIVATE KEY----- + """ + + private let ecdsaP256OpenSSH = """ + -----BEGIN OPENSSH PRIVATE KEY----- + b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS + 1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTqH89eLhVZfNBi53Uv8eRe0ftx3EAQ + uUiKh99TbODk/BLv8912+p9V4h60xMKvXOk7hDw9iqoXK4E9a49+4WMYAAAAqHCR8Ahwkf + AIAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBOofz14uFVl80GLn + dS/x5F7R+3HcQBC5SIqH31Ns4OT8Eu/z3Xb6n1XiHrTEwq9c6TuEPD2KqhcrgT1rj37hYx + gAAAAhAMp+NnVZlln1HtBxI3pQJu3vgCvnNJrJk4F3E6EYKytPAAAAC2d0ZXJtLWVjZHNh + AQIDBA== + -----END OPENSSH PRIVATE KEY----- + """ + + func testParsesEd25519OpenSSH() throws { + let parsed = try SSHKeyParser.parse(ed25519OpenSSH) + XCTAssertEqual(parsed.type, "Ed25519") + } + + func testParsesECDSAP256OpenSSH() throws { + let parsed = try SSHKeyParser.parse(ecdsaP256OpenSSH) + XCTAssertEqual(parsed.type, "ECDSA P-256") + } + + func testEncryptedOpenSSHThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse(ed25519Encrypted)) { error in + XCTAssertEqual(error as? SSHKeyError, .encrypted) + } + } + + func testRSAThrowsUnsupported() { + let rsa = "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----" + XCTAssertThrowsError(try SSHKeyParser.parse(rsa)) { error in + XCTAssertEqual(error as? SSHKeyError, .unsupportedType("ssh-rsa")) + } + } + + func testPublicKeyThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFkeTUyaPwI3q+HM6qK0s/wnVByl92lb9tCI1TFXG1sT comment")) { error in + XCTAssertEqual(error as? SSHKeyError, .notAPrivateKey) + } + } + + func testEmptyThrows() { + XCTAssertThrowsError(try SSHKeyParser.parse(" \n ")) { error in + XCTAssertEqual(error as? SSHKeyError, .malformed("empty")) + } + } + + func testPEMECDSAAllowsSurroundingWhitespace() throws { + let key = P256.Signing.PrivateKey() + let pem = "\n\n" + key.pemRepresentation + "\n\n" + let parsed = try SSHKeyParser.parse(pem) + XCTAssertEqual(parsed.type, "ECDSA P-256") + } + + func testParseUsableSkipsBadKeysButKeepsGood() throws { + let texts = [ + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", + ed25519OpenSSH, + "", + "not a key", + ] + let result = SSHKeyParser.parseUsable(texts) + XCTAssertEqual(result.keys.count, 1) + XCTAssertEqual(result.keys[0].type, "Ed25519") + XCTAssertNotNil(result.firstError) + } + + func testParseUsableAllBadReportsFirstError() { + let result = SSHKeyParser.parseUsable([ + "-----BEGIN RSA PRIVATE KEY-----\nAAAA\n-----END RSA PRIVATE KEY-----", + "ssh-ed25519 AAAA public", + ]) + XCTAssertTrue(result.keys.isEmpty) + XCTAssertEqual(result.firstError as? SSHKeyError, .unsupportedType("ssh-rsa")) + } +} + +extension SSHKeyError: Equatable { + static func == (lhs: SSHKeyError, rhs: SSHKeyError) -> Bool { + switch (lhs, rhs) { + case (.encrypted, .encrypted), (.notAPrivateKey, .notAPrivateKey): + return true + case (.unsupportedType(let a), .unsupportedType(let b)): + return a == b + case (.malformed(let a), .malformed(let b)): + return a == b + default: + return false + } + } +} diff --git a/Tests/SSHPortTests.swift b/Tests/SSHPortTests.swift new file mode 100644 index 0000000..1e70204 --- /dev/null +++ b/Tests/SSHPortTests.swift @@ -0,0 +1,20 @@ +import XCTest + +final class SSHPortTests: XCTestCase { + func testAcceptsTypicalPorts() { + XCTAssertEqual(SSHPort.parse("22"), 22) + XCTAssertEqual(SSHPort.parse("2222"), 2222) + XCTAssertEqual(SSHPort.parse("65535"), 65535) + XCTAssertEqual(SSHPort.parse(" 443 "), 443) + } + + func testRejectsOutOfRangeAndJunk() { + XCTAssertNil(SSHPort.parse("0")) + XCTAssertNil(SSHPort.parse("-1")) + XCTAssertNil(SSHPort.parse("65536")) + XCTAssertNil(SSHPort.parse("999999")) + XCTAssertNil(SSHPort.parse("")) + XCTAssertNil(SSHPort.parse("ssh")) + XCTAssertNil(SSHPort.parse("22a")) + } +} diff --git a/Tests/SessionStateNotifierTests.swift b/Tests/SessionStateNotifierTests.swift new file mode 100644 index 0000000..86aa9c2 --- /dev/null +++ b/Tests/SessionStateNotifierTests.swift @@ -0,0 +1,65 @@ +import XCTest + +final class SessionStateNotifierTests: XCTestCase { + func testNotifyDeliversWhenNotStopped() { + let exp = expectation(description: "delivered") + var seen: [SSHSessionState] = [] + let notifier = SessionStateNotifier { state in + seen.append(state) + exp.fulfill() + } + notifier.notify(.connecting) + wait(for: [exp], timeout: 1) + XCTAssertEqual(seen, [.connecting]) + } + + /// The skeptic race: `notify(.connected)` hops to main without the stopped + /// flag set yet; `stop()` then sets it. The main callback must drop the + /// update so the UI cannot stick on `.connected`. + func testLateConnectedDroppedAfterStop() { + let delivered = expectation(description: "must not deliver") + delivered.isInverted = true + let notifier = SessionStateNotifier { _ in + delivered.fulfill() + } + notifier.notify(.connected) + notifier.setStopped(true) + wait(for: [delivered], timeout: 0.3) + } + + func testNotifyAfterStopIsDropped() { + let delivered = expectation(description: "must not deliver") + delivered.isInverted = true + let notifier = SessionStateNotifier { _ in + delivered.fulfill() + } + notifier.setStopped(true) + notifier.notify(.failed("late")) + notifier.notify(.closed) + wait(for: [delivered], timeout: 0.3) + } + + func testFailedDoesNotOverwriteAfterStop() { + let connecting = expectation(description: "connecting") + let extra = expectation(description: "no extra after stop") + extra.expectedFulfillmentCount = 2 + extra.isInverted = true + var seen: [SSHSessionState] = [] + let notifier = SessionStateNotifier { state in + seen.append(state) + if state == .connecting { + connecting.fulfill() + } else { + extra.fulfill() + } + } + notifier.notify(.connecting) + wait(for: [connecting], timeout: 1) + XCTAssertEqual(seen, [.connecting]) + notifier.setStopped(true) + notifier.notify(.connected) + notifier.notify(.failed("channel closed")) + wait(for: [extra], timeout: 0.3) + XCTAssertEqual(seen, [.connecting]) + } +} diff --git a/project.yml b/project.yml index 08a468c..7d9ba3b 100644 --- a/project.yml +++ b/project.yml @@ -116,9 +116,20 @@ targets: sources: - path: Tests - path: Sources/LLM + # Foundation-safe units covered by the audit (no GhosttyKit / UIKit). + - path: Sources/SSH/SSHKeyParser.swift + - path: Sources/SSH/KnownHosts.swift + - path: Sources/SSH/ChannelCloseOnce.swift + - path: Sources/SSH/SessionStateNotifier.swift + - path: Sources/SSH/SSHPort.swift + - path: Sources/Model/CommandHistory.swift dependencies: - package: OpenAI - package: SwiftAnthropic + - package: SwiftNIOSSH + product: NIOSSH + - package: SwiftCrypto + product: Crypto settings: base: PRODUCT_BUNDLE_IDENTIFIER: io.github.madeye.gtermTests