Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions Sources/LLM/CommandAssistant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}
}

Expand Down
26 changes: 21 additions & 5 deletions Sources/LLM/Completion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down
28 changes: 19 additions & 9 deletions Sources/LLM/LLMProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
51 changes: 42 additions & 9 deletions Sources/Model/CommandHistory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[..<eq].lowercased()
if name.contains("password") || name.contains("passwd")
|| name.contains("secret") || name.contains("token")
|| name.contains("apikey") || name.contains("api_key")
|| name.contains("api-key") {
return true
}
}
return false
}
}
18 changes: 18 additions & 0 deletions Sources/SSH/ChannelCloseOnce.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import Foundation

/// Delivers a channel-close callback at most once.
///
/// SSH child channels commonly see `errorCaught` followed by `channelInactive`
/// (because the error path closes the channel). Without this, the session
/// reports `.failed` and then `.closed`, dropping the error.
struct ChannelCloseOnce {
private var fired = false

/// Forward `error` to `handler` the first time only. Later calls are ignored,
/// so a subsequent clean inactive does not wipe a previous error.
mutating func deliver(_ error: Error?, to handler: (Error?) -> Void) {
guard !fired else { return }
fired = true
handler(error)
}
}
21 changes: 17 additions & 4 deletions Sources/SSH/KnownHosts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:<unknown>"
}
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)
}
}

Expand Down
5 changes: 3 additions & 2 deletions Sources/SSH/PTYChannelHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ final class PTYChannelHandler: ChannelDuplexHandler {
private let onClose: (Error?) -> Void

private var context: ChannelHandlerContext?
private var closeOnce = ChannelCloseOnce()

init(
term: String,
Expand Down Expand Up @@ -66,7 +67,7 @@ final class PTYChannelHandler: ChannelDuplexHandler {
}

func channelInactive(context: ChannelHandlerContext) {
onClose(nil)
closeOnce.deliver(nil, to: onClose)
context.fireChannelInactive()
}

Expand All @@ -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)
}

Expand Down
16 changes: 16 additions & 0 deletions Sources/SSH/PortForwardManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ final class PortForwardManager {
/// off->on toggle would otherwise fail with EADDRINUSE.
private var closing: [UUID: EventLoopFuture<Void>] = [:]

/// 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,
Expand All @@ -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 }
Expand All @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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<Void>] = []
for ch in self.listeners.values { closes.append(ch.close().recover { _ in () }) }
self.listeners.removeAll()
Expand Down
Loading
Loading