Files

2996 lines
143 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import CSSH2
import Darwin
import Foundation
import Logging
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// Installs a small cwd-reporting hook before remote output reaches Ghostty.
/// The probe, its echo, and the installation command stay inside this state
/// machine; only the login banner and the final integrated prompt are emitted.
public nonisolated struct RemoteShellIntegrationBootstrap: Codable, Sendable {
enum Diagnostic: Sendable, Equatable {
enum TimeoutStage: String, Sendable {
case lateProbe = "late_probe"
case installation
}
enum SkipReason: String, Sendable {
case unsupportedShell = "unsupported_shell"
case userInput = "user_input"
}
case probeDelayed
case installed(late: Bool, homeResolved: Bool, systemInfoAvailable: Bool)
case skipped(SkipReason)
case timedOut(stage: TimeoutStage, late: Bool)
}
public nonisolated struct Update: Sendable {
public var command: Data?
public var visibleOutput: Data?
public var isComplete = false
public var integrationInstalled = false
public var shell: String?
public var systemInfo: RemoteSystemInfo?
public var homeDirectory: String?
public var historySource: ShellHistorySource?
public var homeResolutionUnavailable = false
var diagnostic: Diagnostic?
}
private enum Phase: Codable, Sendable {
case probing
case scanningForLateProbe
case suppressingLateProbe
case installing
case complete
}
private static let responseTimeout: UInt64 = 3_000_000_000
private static let quietTimeout: UInt64 = 500_000_000
public let token: String
public let startCommand: Data
private let charset: RemoteCharset
private var deadline: UInt64
private var phase: Phase = .probing
private var buffer = Data()
private var preservedOutput = Data()
private var lastActivity: UInt64?
private var userDidWrite = false
private var installationStartedLate = false
private var detectedShell: String?
private var detectedSystemInfo: RemoteSystemInfo?
private var probeEchoMarker: Data {
Data("__TRMD_\(token)_PROBE__".utf8)
}
public init(
token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""),
charset: RemoteCharset = .utf8,
now: UInt64 = DispatchTime.now().uptimeNanoseconds
) {
self.token = token
self.charset = charset
// The POSIX helper reads os-release as inert assignment text instead of
// sourcing it. Its output precedes the shell marker, so a missing `sh`
// still leaves the original shell-only bootstrap path usable.
// Put a token near the start of the line so the bootstrap echo can still
// be recognized when zsh or fish redraws the remaining command with
// syntax-highlighting escape sequences. `true` accepts the inert token
// in all supported shells and produces no output.
var probeCommand = " command true '__TRMD_\(token)_PROBE__'; "
probeCommand += "command sh -c 'f=; did=; dlike=; dver=; "
probeCommand += "[ -r /etc/os-release ] && f=/etc/os-release; "
probeCommand += "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; "
probeCommand += "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in "
probeCommand += "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; "
probeCommand += "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; "
probeCommand += "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); "
probeCommand += "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); "
probeCommand += "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" "
probeCommand += "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; "
probeCommand += "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n"
startCommand = Data(probeCommand.utf8)
deadline = now + Self.responseTimeout
}
public mutating func noteUserInput() {
userDidWrite = true
}
/// Monotonic deadlines from another process cannot be reused after restoration.
mutating func resetDeadlinesAfterRestore(now: UInt64 = DispatchTime.now().uptimeNanoseconds) {
deadline = now + Self.responseTimeout
if lastActivity != nil { lastActivity = now }
}
public mutating func receive(
_ data: Data, now: UInt64 = DispatchTime.now().uptimeNanoseconds
) -> Update {
guard phase != .complete else {
return Update(visibleOutput: data, isComplete: true)
}
buffer.append(data)
switch phase {
case .probing:
return receiveProbe(now: now, isLate: false)
case .scanningForLateProbe:
return scanForLateProbe(now: now)
case .suppressingLateProbe:
lastActivity = now
return receiveProbe(now: now, isLate: true)
case .installing:
return receiveInstallation()
case .complete:
return Update(visibleOutput: data, isComplete: true)
}
}
/// Advances time-dependent bootstrap work. The first probe timeout restores
/// the terminal but deliberately keeps a streaming signature scanner alive:
/// a slow remote may echo the probe only after the UI is interactive again.
public mutating func poll(
now: UInt64 = DispatchTime.now().uptimeNanoseconds
) -> Update {
switch phase {
case .probing where now >= deadline:
let isEchoingProbe = buffer.range(of: probeEchoMarker) != nil
let visible = cleanBufferedOutput()
phase = isEchoingProbe ? .suppressingLateProbe : .scanningForLateProbe
if isEchoingProbe { deadline = now + Self.responseTimeout }
lastActivity = nil
return Update(
visibleOutput: visible, homeResolutionUnavailable: true,
diagnostic: .probeDelayed
)
case .scanningForLateProbe:
guard let lastActivity, now >= lastActivity + Self.quietTimeout,
!buffer.isEmpty
else { return Update() }
let visible = buffer
buffer.removeAll(keepingCapacity: false)
self.lastActivity = nil
return Update(visibleOutput: visible)
case .suppressingLateProbe:
guard
now >= deadline
|| lastActivity.map({ now >= $0 + Self.quietTimeout }) == true
else { return Update() }
let visible = outputAfterLastLineBreak(in: buffer)
finish()
return Update(
visibleOutput: visible, isComplete: true,
diagnostic: .timedOut(stage: .lateProbe, late: true)
)
case .installing where now >= deadline:
let visible =
installationStartedLate
? outputAfterLastLineBreak(in: buffer)
: cleanBufferedOutput()
finish()
return Update(
visibleOutput: visible, isComplete: true,
diagnostic: .timedOut(stage: .installation, late: installationStartedLate)
)
case .probing, .installing, .complete:
return Update()
}
}
private mutating func receiveProbe(now: UInt64, isLate: Bool) -> Update {
let prefix = Data("__TRMD_\(token)_SHELL__".utf8)
guard let prefixRange = buffer.range(of: prefix) else { return Update() }
let shellStart = prefixRange.upperBound
let terminator = Data("__".utf8)
guard
let terminatorRange = buffer.range(
of: terminator, in: shellStart..<buffer.endIndex
),
let lineEnd = buffer[terminatorRange.upperBound...].firstIndex(of: 0x0A)
else { return Update() }
if !isLate {
preservedOutput = removingSystemInfoMarker(
from: outputBeforeCommand(
in: Data(buffer[..<prefixRange.lowerBound])
)
)
}
let shellBytes = Data(buffer[shellStart..<terminatorRange.lowerBound])
let shellPath =
(try? RemoteCharsetStreamConverter.decode(shellBytes, charset: charset))
?? String(decoding: shellBytes, as: UTF8.self)
let shell = shellPath.split(separator: "/").last.map(String.init)?.lowercased()
detectedShell = shell
detectedSystemInfo = parseSystemInfo(in: buffer)
let suffix = Data(buffer[buffer.index(after: lineEnd)...])
buffer.removeAll(keepingCapacity: false)
guard let command = installationCommand(for: shell) else {
var visible = preservedOutput
visible.append(suffix)
finish()
return Update(
visibleOutput: visible, isComplete: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
diagnostic: .skipped(.unsupportedShell)
)
}
guard !(isLate && userDidWrite) else {
var visible = preservedOutput
visible.append(suffix)
finish()
return Update(
visibleOutput: visible, isComplete: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
diagnostic: .skipped(.userInput)
)
}
phase = .installing
installationStartedLate = isLate
deadline = now + Self.responseTimeout
lastActivity = nil
return Update(command: command)
}
private mutating func receiveInstallation() -> Update {
let marker = Data("__TRMD_\(token)_DONE__".utf8)
guard let markerRange = buffer.range(of: marker),
let lineEnd = buffer[markerRange.upperBound...].firstIndex(of: 0x0A)
else { return Update() }
var visible = preservedOutput
visible.append(Data(buffer[buffer.index(after: lineEnd)...]))
let homeDirectory = parseHomeDirectory(in: buffer)
let historySource = parseHistorySource(in: buffer)
finish()
return Update(
visibleOutput: visible, isComplete: true,
integrationInstalled: true, shell: detectedShell,
systemInfo: detectedSystemInfo,
homeDirectory: homeDirectory, historySource: historySource,
diagnostic: .installed(
late: installationStartedLate, homeResolved: homeDirectory != nil,
systemInfoAvailable: detectedSystemInfo != nil
)
)
}
private func parseHistorySource(in data: Data) -> ShellHistorySource? {
guard let shell = detectedShell.flatMap(ShellHistorySource.Shell.init(rawValue:)) else { return nil }
let prefix = Data("__TRMD_\(token)_HISTORY__".utf8)
guard let start = data.range(of: prefix)?.upperBound,
let end = data[start...].firstIndex(of: 0x0A)
else { return nil }
var payload = Data(data[start..<end])
if payload.last == 0x0D { payload.removeLast() }
let path = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset)
guard !path.unicodeScalars.contains(where: { CharacterSet.controlCharacters.contains($0) }) else { return nil }
return ShellHistorySource(shell: shell, path: path)
}
private mutating func parseHomeDirectory(in data: Data) -> String? {
let prefix = Data("__TRMD_\(token)_HOME__".utf8)
guard let prefixRange = data.range(of: prefix),
let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A)
else { return nil }
var payload = Data(data[prefixRange.upperBound..<lineEnd])
if payload.last == 0x0D { payload.removeLast() }
let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset)
guard
decoded.hasPrefix("/"),
!decoded.unicodeScalars.contains(where: {
CharacterSet.controlCharacters.contains($0)
|| CharacterSet.newlines.contains($0)
})
else { return nil }
return decoded
}
fileprivate mutating func parseSystemInfo(in data: Data) -> RemoteSystemInfo? {
let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8)
guard let prefixRange = data.range(of: prefix),
let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A)
else { return nil }
var payload = Data(data[prefixRange.upperBound..<lineEnd])
if payload.last == 0x0D { payload.removeLast() }
guard payload.count >= 2, payload.suffix(2).elementsEqual(Data("__".utf8))
else { return nil }
payload.removeLast(2)
let fields =
(try? RemoteCharsetStreamConverter.decode(payload, charset: charset))
?? String(decoding: payload, as: UTF8.self)
return RemoteSystemInfo(probeFields: fields)
}
private func removingSystemInfoMarker(from data: Data) -> Data {
let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8)
guard let prefixRange = data.range(of: prefix),
let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A)
else { return data }
var result = Data(data[..<prefixRange.lowerBound])
result.append(Data(data[data.index(after: lineEnd)...]))
return result
}
private mutating func scanForLateProbe(now: UInt64) -> Update {
let echo = Data(startCommand.dropFirst().dropLast())
let marker = Data("__TRMD_\(token)_SHELL__".utf8)
let targets = [echo, probeEchoMarker, marker]
if let match = earliestMatch(of: targets, in: buffer) {
var suppressionStart: Data.Index
if match.targetIndex == 1 {
suppressionStart = startOfLine(containing: match.range.lowerBound, in: buffer)
} else {
suppressionStart = match.range.lowerBound
}
if match.targetIndex == 0, suppressionStart > buffer.startIndex {
let preceding = buffer.index(before: suppressionStart)
if buffer[preceding] == 0x20 { suppressionStart = preceding }
}
let visible = Data(buffer[..<suppressionStart])
buffer = Data(buffer[suppressionStart...])
phase = .suppressingLateProbe
lastActivity = now
deadline = now + Self.responseTimeout
let probe = receiveProbe(now: now, isLate: true)
return mergingVisible(visible, into: probe)
}
var retainedCount = longestSuffixMatchingPrefix(of: targets, in: buffer)
if retainedCount > 0, buffer.count > retainedCount {
let retainedStart = buffer.index(buffer.endIndex, offsetBy: -retainedCount)
let preceding = buffer.index(before: retainedStart)
if buffer[preceding] == 0x20 { retainedCount += 1 }
}
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retainedCount)
let visible = Data(buffer[..<visibleEnd])
buffer = Data(buffer[visibleEnd...])
lastActivity = buffer.isEmpty ? nil : now
return Update(visibleOutput: visible.isEmpty ? nil : visible)
}
private func earliestMatch(of targets: [Data], in data: Data)
-> (range: Range<Data.Index>, targetIndex: Int)?
{
targets.enumerated().compactMap { index, target in
data.range(of: target).map { (range: $0, targetIndex: index) }
}.min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound }
}
private func longestSuffixMatchingPrefix(of targets: [Data], in data: Data) -> Int {
let maximum = targets.map(\.count).max() ?? 0
guard !data.isEmpty, maximum > 1 else { return 0 }
for count in stride(from: min(data.count, maximum - 1), through: 1, by: -1) {
if targets.contains(where: { target in
target.count >= count && data.suffix(count).elementsEqual(target.prefix(count))
}) {
return count
}
}
return 0
}
private func mergingVisible(_ visible: Data, into update: Update) -> Update {
guard !visible.isEmpty else { return update }
var combined = visible
if let following = update.visibleOutput { combined.append(following) }
return Update(
command: update.command, visibleOutput: combined,
isComplete: update.isComplete,
integrationInstalled: update.integrationInstalled,
shell: update.shell, systemInfo: update.systemInfo,
homeDirectory: update.homeDirectory, historySource: update.historySource,
homeResolutionUnavailable: update.homeResolutionUnavailable,
diagnostic: update.diagnostic
)
}
private mutating func cleanBufferedOutput() -> Data {
var visible = preservedOutput
if visible.isEmpty { visible = outputBeforeCommand(in: buffer) }
let suffix: Data
if let markerRange = buffer.range(of: probeEchoMarker),
buffer[markerRange.upperBound...].firstIndex(of: 0x0A) == nil
{
// The line editor has drawn the probe but the shell has not yet
// accepted the line. Keeping the unfinished line would reveal it.
suffix = Data()
} else {
suffix = outputAfterLastLineBreak(in: buffer)
}
if !suffix.isEmpty { visible.append(suffix) }
buffer.removeAll(keepingCapacity: false)
preservedOutput.removeAll(keepingCapacity: false)
return visible
}
private func outputAfterLastLineBreak(in data: Data) -> Data {
guard let lineEnd = data.lastIndex(of: 0x0A),
data.index(after: lineEnd) < data.endIndex
else { return Data() }
return Data(data[data.index(after: lineEnd)...])
}
private mutating func finish() {
phase = .complete
buffer.removeAll(keepingCapacity: false)
preservedOutput.removeAll(keepingCapacity: false)
lastActivity = nil
}
private func outputBeforeCommand(in data: Data) -> Data {
let echo = Data(startCommand.dropLast())
let boundary: Data.Index
if let echoRange = data.range(of: echo) {
boundary = echoRange.lowerBound
} else if let markerRange = data.range(of: probeEchoMarker) {
boundary = startOfLine(containing: markerRange.lowerBound, in: data)
} else {
boundary = data.endIndex
}
let prefix = data[..<boundary]
guard let lineEnd = prefix.lastIndex(of: 0x0A) else { return Data() }
return Data(prefix[...lineEnd])
}
private func startOfLine(containing index: Data.Index, in data: Data) -> Data.Index {
data[..<index].lastIndex(of: 0x0A).map { data.index(after: $0) }
?? data.startIndex
}
func installationCommand(for shell: String?, runtime: Bool = false) -> Data? {
let cwdReport =
runtime
? "__termind_event directory \"$(__termind_encode \"$PWD\")\""
: "builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\""
let bashFinished =
runtime
? "__termind_event finish \"$status\" \"$duration\""
: "builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\""
let zshFinished =
runtime
? "__termind_event finish \"$command_status\" \"$duration\""
: "builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\""
let promptReport = runtime ? "__termind_event prompt" : "builtin printf '\\e]777;TermindPrompt=\(token)\\a'"
let bashStarted =
runtime
? "__termind_event start \"$(__termind_encode \"$cmd\")\""
: "builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\""
let zshStarted =
runtime
? "__termind_event start \"$(__termind_encode \"$1\")\""
: "builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\""
let bashDraft =
runtime
? RemoteShellContextJournal.draftWidget(shell: "bash", replacing: false)
: "builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\""
let zshDraft =
runtime
? RemoteShellContextJournal.draftWidget(shell: "zsh", replacing: false)
: "builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\""
let fishCwd =
runtime
? "__termind_event directory (string escape --style=url -- $PWD)"
: "printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD)"
let fishPrompt = runtime ? "__termind_event prompt" : "printf '\\e]777;TermindPrompt=\(token)\\a'"
let fishStarted =
runtime
? "__termind_event start (string escape --style=url -- $argv[1])"
: "printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1])"
let fishFinished =
runtime
? "__termind_event finish $command_status $command_duration"
: "printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration"
let fishDraft =
runtime
? RemoteShellContextJournal.draftWidget(shell: "fish", replacing: false)
: "printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\""
let bashClear =
runtime
? RemoteShellContextJournal.draftWidget(shell: "bash", replacing: true) : "READLINE_LINE=; READLINE_POINT=0"
let zshClear =
runtime ? RemoteShellContextJournal.draftWidget(shell: "zsh", replacing: true) : "BUFFER=; CURSOR=0"
let fishClear =
runtime
? RemoteShellContextJournal.draftWidget(shell: "fish", replacing: true)
: "commandline -r ''; commandline -C 0"
let marker =
"builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'"
let historyMarker =
"__termind_history_path=\"${HISTFILE-}\"; if [[ -n \"$__termind_history_path\" && \"$__termind_history_path\" != /* ]]; then __termind_history_path=\"$PWD/$__termind_history_path\"; fi; builtin printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' \"$(__termind_encode \"$__termind_history_path\")\"; unset __termind_history_path; "
let fishMetadata =
"set -l __termind_hname fish; if set -q fish_history; set __termind_hname \"$fish_history\"; end; set -l __termind_hbase \"$HOME/.local/share\"; if set -q XDG_DATA_HOME; and test -n \"$XDG_DATA_HOME\"; set __termind_hbase \"$XDG_DATA_HOME\"; end; set -l __termind_hpath ''; if test -n \"$__termind_hname\"; set __termind_hpath \"$__termind_hbase/fish/$__termind_hname\"_history; end; printf '\\n__TRMD_%s_HISTORY__%s\\n' '\(token)' (string escape --style=url -- \"$__termind_hpath\"); set -e __termind_hname __termind_hbase __termind_hpath; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; "
let command: String
switch shell {
case "bash":
// The fallback DEBUG trap also observes commands run by PROMPT_COMMAND.
// Keep capture closed around the existing prompt hooks, then report
// the saved user-command status only after those hooks finish.
command =
[
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then",
"__termind_integration_token=\"\(token)\";",
"__termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; };",
"__termind_report_cwd() { \(cwdReport); };",
"__termind_report_prompt() { builtin local status=$? duration=0; if (( $# > 0 )); then status=$1; fi; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; \(bashFinished); builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; \(promptReport); __termind_command_ready=1; return \"$status\"; };",
"__termind_prompt_begin() { __termind_prompt_status=$?; __termind_command_ready=0; return \"$__termind_prompt_status\"; };",
"__termind_prompt_end() { builtin local status=\"${__termind_prompt_status:-$?}\"; builtin unset __termind_prompt_status; __termind_report_prompt \"$status\"; };",
"__termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; case \"$cmd\" in __termind_*) return \"$status\";; esac; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then \(bashStarted); __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; };",
"__termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; };",
"__termind_compose_draft() { \(bashDraft); };",
"__termind_compose_clear() { \(bashClear); };",
"for __termind_map in emacs-standard vi-insert vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done;",
"builtin unset __termind_map;",
"if [[ -n \"${bash_preexec_imported:-}\" ]]; then",
"[[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command);",
"[[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\");",
"else",
"__termind_debug_trap=\"$(__termind_get_trap DEBUG)\";",
"if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command \"$BASH_COMMAND\"' DEBUG; else builtin trap '__termind_report_command \"$BASH_COMMAND\"' DEBUG; fi; fi;",
"if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then",
"[[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_prompt_begin \"* ]] || PROMPT_COMMAND=(__termind_prompt_begin \"${PROMPT_COMMAND[@]}\" __termind_prompt_end);",
"elif [[ \"${PROMPT_COMMAND:-}\" != *\"__termind_prompt_begin\"* ]]; then",
"PROMPT_COMMAND=\"__termind_prompt_begin\"$'\\n'\"${PROMPT_COMMAND:-}\"$'\\n'\"__termind_prompt_end\";",
"fi;",
"fi;",
"fi;",
runtime ? "" : "\(historyMarker)\(marker);",
"__termind_report_prompt",
].joined(separator: " ") + "\n"
case "zsh":
// Unlike bash, zsh reserves `status` as a read-only special parameter.
// Assigning it aborts this function and the remaining prompt hooks.
command =
" if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { \(cwdReport); }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; \(zshFinished); unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; \(promptReport); return \"$command_status\"; }; function __termind_report_command { \(zshStarted); typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { \(zshDraft); zle redisplay; }; function __termind_compose_clear { \(zshClear); zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(runtime ? "" : historyMarker + marker + "; ")__termind_report_prompt\n"
case "fish":
command =
" if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; \(fishCwd); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; \(fishPrompt); end; function __termind_report_command --on-event fish_preexec; \(fishStarted); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; \(fishFinished); set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); \(fishDraft); end; function __termind_compose_clear; \(fishClear); end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; \(runtime ? "" : fishMetadata)__termind_report_prompt\n"
default:
return nil
}
return Data(command.utf8)
}
}
/// Removes authenticated command-report OSC sequences before output reaches
/// Ghostty and emits their decoded payloads separately. The session token makes
/// ordinary OSC output in a remote program ineligible to forge local history.
public nonisolated struct RemoteCommandEventFilter: Codable, Sendable {
public static let maximumPayloadBytes = 64 * 1024
public nonisolated enum Event: Sendable, Equatable {
case commandStarted(String)
case commandFinished(Int32, duration: TimeInterval?)
case promptReady
case draft(String)
}
public nonisolated struct Update: Sendable {
public var visibleOutput = Data()
public var events: [Event] = []
}
private let commandPrefix: Data
private let commandFinishedPrefix: Data
private let promptPrefix: Data
private let draftPrefix: Data
private let charset: RemoteCharset
private var buffer = Data()
public init(token: String, charset: RemoteCharset = .utf8) {
commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8)
commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8)
promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8)
draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8)
self.charset = charset
}
public mutating func receive(_ data: Data) -> Update {
buffer.append(data)
var update = Update()
while let match = earliestMarker() {
let marker = match.range
if marker.lowerBound > buffer.startIndex {
update.visibleOutput.append(buffer[..<marker.lowerBound])
}
guard let end = buffer[marker.upperBound...].firstIndex(of: 0x07) else {
if buffer.distance(from: marker.upperBound, to: buffer.endIndex)
> Self.maximumPayloadBytes
{
// A forged or truncated authenticated marker must not retain
// the terminal stream forever. Drop the malformed frame and
// resume with the next received bytes.
buffer.removeAll(keepingCapacity: false)
} else {
buffer = Data(buffer[marker.lowerBound...])
}
return update
}
let payload = Data(buffer[marker.upperBound..<end])
guard payload.count <= Self.maximumPayloadBytes else {
buffer = Data(buffer[buffer.index(after: end)...])
continue
}
switch match.kind {
case .command:
let command = Self.decodePercentEncoded(payload, charset: charset)
if !command.isEmpty {
update.events.append(.commandStarted(command))
}
case .commandFinished:
let fields = String(decoding: payload, as: UTF8.self)
.split(separator: ";", omittingEmptySubsequences: false)
if let statusText = fields.first, fields.count <= 2,
let status = Int32(statusText), (0...255).contains(status)
{
let duration =
fields.count == 2
? Self.duration(milliseconds: fields[1])
: nil
update.events.append(.commandFinished(status, duration: duration))
}
case .prompt:
guard payload.isEmpty else { break }
update.events.append(.promptReady)
case .draft:
update.events.append(.draft(Self.decodePercentEncoded(payload, charset: charset)))
}
buffer = Data(buffer[buffer.index(after: end)...])
}
let retained = longestSuffixMatchingPrefix()
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retained)
update.visibleOutput.append(buffer[..<visibleEnd])
buffer = Data(buffer[visibleEnd...])
return update
}
private func longestSuffixMatchingPrefix() -> Int {
let prefixes = [commandPrefix, commandFinishedPrefix, promptPrefix, draftPrefix]
guard !buffer.isEmpty else { return 0 }
let maximum = prefixes.map(\.count).max() ?? 0
guard maximum > 1 else { return 0 }
for count in stride(from: min(buffer.count, maximum - 1), through: 1, by: -1) {
if prefixes.contains(where: {
$0.count >= count && buffer.suffix(count).elementsEqual($0.prefix(count))
}) {
return count
}
}
return 0
}
private enum MarkerKind { case command, commandFinished, prompt, draft }
private func earliestMarker() -> (range: Range<Data.Index>, kind: MarkerKind)? {
let candidates: [(Range<Data.Index>, MarkerKind)] = [
buffer.range(of: commandPrefix).map { ($0, .command) },
buffer.range(of: commandFinishedPrefix).map { ($0, .commandFinished) },
buffer.range(of: promptPrefix).map { ($0, .prompt) },
buffer.range(of: draftPrefix).map { ($0, .draft) },
].compactMap { $0 }
return candidates.min { $0.0.lowerBound < $1.0.lowerBound }
}
public static func decodePercentEncoded(_ data: Data, charset: RemoteCharset = .utf8) -> String {
let bytes = Array(data)
var decoded: [UInt8] = []
decoded.reserveCapacity(bytes.count)
var index = 0
while index < bytes.count {
if bytes[index] == 0x25, index + 2 < bytes.count,
let high = SSHWireEncoding.hexDigit(bytes[index + 1]),
let low = SSHWireEncoding.hexDigit(bytes[index + 2])
{
decoded.append(high << 4 | low)
index += 3
} else {
decoded.append(bytes[index])
index += 1
}
}
let decodedData = Data(decoded)
return (try? RemoteCharsetStreamConverter.decode(decodedData, charset: charset))
?? String(decoding: decodedData, as: UTF8.self)
}
private static func duration(milliseconds text: Substring) -> TimeInterval? {
guard let milliseconds = UInt64(text), milliseconds <= 31_536_000_000 else {
return nil
}
return Double(milliseconds) / 1_000
}
}
private nonisolated final class SSHPTYStartupBytes: @unchecked Sendable {
let pointer: UnsafeMutablePointer<CChar>
let count: UInt32
init(_ data: Data) {
count = UInt32(data.count)
pointer = .allocate(capacity: data.count + 1)
for (index, byte) in data.enumerated() { pointer[index] = CChar(bitPattern: byte) }
pointer[data.count] = 0
}
deinit { pointer.deallocate() }
}
public actor SSHPTYChannel {
public typealias OutputHandler = @Sendable (Data) -> Void
public typealias ExitHandler = @Sendable (Result<Int32, TermindSSHError>) -> Void
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
public typealias StartupPreparation =
@Sendable (@escaping RemoteSessionCommandExecutor) async throws -> SSHPTYStartupPlan?
private nonisolated let worker: SSHPTYChannelWorker
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
private let agentForwardingFactory: SSHAgentForwardingFactory?
private var environmentVariables: [HostEnvironmentVariable] = []
private var environmentRejection: EnvironmentRejectionHandler = { _ in }
private var startupPreparation: StartupPreparation?
private let transportOutput: OutputHandler
private let transportIntegration: ShellIntegrationHandler
private let transportEngines: ResumableTerminalEngineRegistry
private var resumable: ResumableTerminalClient?
private var transportOptions = HostTerminalTransports()
private var transportKind: HostTerminalProtocol = .ssh
private var transportCheckpoint: TerminalTransportCheckpoint?
private var transportRoute: ResumableTerminalClient.RouteFactory?
private var transportControl: RemoteSessionCommandExecutor?
private var transportState: ResumableTerminalClient.StateHandler = { _ in }
private var transportRouteChanged: @Sendable (TerminalRouteSnapshot) -> Void = { _ in }
public init(
output: @escaping OutputHandler, exit: @escaping ExitHandler,
shellIntegration: @escaping ShellIntegrationHandler = { _ in },
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil,
agentForwardingFactory: SSHAgentForwardingFactory? = nil,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy(),
transportEngines: ResumableTerminalEngineRegistry = .live
) {
transportOutput = output
transportIntegration = shellIntegration
self.transportEngines = transportEngines
self.keyboardInteractive = keyboardInteractive
self.agentForwardingFactory = agentForwardingFactory
worker = SSHPTYChannelWorker(
output: output, exit: exit, shellIntegrationHandler: shellIntegration, logger: logger,
maxSessionsPolicy: maxSessionsPolicy)
}
public func connect(
using request: SSHConnectionRequest,
shellIntegrationEnabled: Bool = true,
columns: UInt16,
rows: UInt16,
progress: SSHRouteProgressHandler? = nil
) async throws {
defer { request.configuration.clearSecrets() }
var request = request.withKeyboardInteractiveFallback(keyboardInteractive)
if transportKind.isResumable {
request.agentForwarding = nil
request.permitsAgentForwarding = false
guard let transportRoute else { throw TerminalTransportError.unsupportedRoute }
let client = ResumableTerminalClient(
transport: transportKind, checkpoint: transportCheckpoint, output: transportOutput,
stateChanged: transportState, shellIntegration: transportIntegration, registry: transportEngines,
routeChanged: transportRouteChanged)
resumable = client
try await client.connect(
request: request, options: transportOptions, environment: environmentVariables,
shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows,
route: transportRoute, progress: progress, startupPreparation: startupPreparation)
transportCheckpoint = nil
return
}
request.agentForwarding = try await agentForwardingFactory?(request)
try await worker.connect(
using: request,
environmentVariables: environmentVariables,
environmentRejection: environmentRejection,
startupPreparation: startupPreparation,
shellIntegrationEnabled: shellIntegrationEnabled,
columns: columns, rows: rows, progress: progress
)
}
public func configureEnvironment(
_ variables: [HostEnvironmentVariable],
rejection: @escaping EnvironmentRejectionHandler = { _ in }
) {
environmentVariables = variables
environmentRejection = rejection
}
public func configureStartup(_ preparation: @escaping StartupPreparation) {
startupPreparation = preparation
}
public func configureTransport(
_ kind: HostTerminalProtocol, options: HostTerminalTransports,
checkpoint: TerminalTransportCheckpoint? = nil,
route: @escaping ResumableTerminalClient.RouteFactory,
control: RemoteSessionCommandExecutor? = nil,
stateChanged: @escaping ResumableTerminalClient.StateHandler,
routeChanged: @escaping @Sendable (TerminalRouteSnapshot) -> Void = { _ in }
) {
transportKind = kind
transportOptions = options
transportCheckpoint = checkpoint
transportRoute = route
transportControl = control
transportState = stateChanged
transportRouteChanged = routeChanged
}
/// Share route/control wiring while the owner supplies its generation-fenced request factory.
public func configureTransport(
_ kind: HostTerminalProtocol, options: HostTerminalTransports,
checkpoint: TerminalTransportCheckpoint? = nil,
requestFactory: @escaping @Sendable (_ authenticatingTarget: Bool) async throws -> SSHConnectionRequest,
stateChanged: @escaping ResumableTerminalClient.StateHandler,
routeChanged: @escaping @Sendable (TerminalRouteSnapshot) -> Void = { _ in }
) {
configureTransport(
kind, options: options, checkpoint: checkpoint,
route: { progress in
try await TerminalTransportRoute.open(
using: requestFactory(false), port: options.eternalTerminalPort, progress: progress)
},
control: { command in
try await TerminalTransportRoute.execute(command, using: requestFactory(true))
},
stateChanged: stateChanged, routeChanged: routeChanged)
}
public func suspendTransport() async throws -> TerminalTransportCheckpoint {
guard let resumable else { throw TerminalTransportError.invalidCheckpoint }
return try await resumable.suspend()
}
public func resumeTransport() async throws {
guard let resumable else { throw TerminalTransportError.invalidCheckpoint }
try await resumable.resume()
}
public func retryTransport() async throws {
guard let resumable else { throw TerminalTransportError.invalidCheckpoint }
try await resumable.retryNow()
}
public func sessionCommandExecutor() async throws -> RemoteSessionCommandExecutor {
if transportKind.isResumable {
guard let transportControl else { throw TermindSSHError.disconnected }
return transportControl
}
return try await worker.sessionCommandExecutor()
}
public func runtimeCommandExecutor() async throws -> RemoteSessionCommandExecutor {
try await worker.sessionCommandExecutor(runtime: true)
}
public func setTerminalActive(_ active: Bool) {
guard transportKind == .ssh else { return }
worker.setTerminalActive(active)
}
public func contextDraft(_ context: TerminalShellContext, replacing command: String? = nil) async throws -> String {
try await worker.contextDraft(context, replacing: command)
}
public func confirmContext(_ context: TerminalShellContext) async throws {
try await worker.confirmContext(context)
}
public func releaseTerminalForControl() async throws {
if let resumable {
await resumable.close()
return
}
try await worker.releaseTerminalForControl()
}
public func write(_ data: Data) async {
if let resumable {
do {
try await resumable.write(data)
} catch TerminalTransportError.suspended {
// Background checkpoints must remain frozen until eligibility is invalidated.
} catch is CancellationError {
} catch {
transportState(.failed)
}
return
}
worker.write(data)
}
public func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
guard resumable == nil else { return }
await worker.submitInitialCommand(data, ticket: ticket)
}
/// Opens an SFTP subsystem Channel on the PTY Channel's authenticated connection.
/// This is a fallback only: callers must first attempt their ordinary
/// independent SFTP connection.
public func openSFTPChannel() async throws -> any SFTPChannelProtocol {
try await worker.openSFTPChannel()
}
public func executeHistoryRead(
_ command: String, outputLimit: Int, context: TerminalShellContext? = nil
) async throws -> SSHExecResult {
let cancellation = TransferCancellationToken()
return try await withTaskCancellationHandler {
try await worker.executeHistoryRead(
command, outputLimit: outputLimit, cancellation: cancellation, context: context)
} onCancel: {
cancellation.cancel()
}
}
public func historySource() async throws -> ShellHistorySource? {
try await worker.historySource()
}
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) async {
if let resumable {
do { try await resumable.resize(columns: columns, rows: rows) } catch { transportState(.failed) }
return
}
worker.resize(
columns: columns, rows: rows,
widthPixels: widthPixels, heightPixels: heightPixels
)
}
public func disconnect() async {
await resumable?.close()
resumable = nil
transportCheckpoint = nil
transportRoute = nil
transportControl = nil
await worker.disconnect()
}
}
nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
private static let initialization: Void = {
precondition(libssh2_init(0) == 0, "libssh2 initialization failed")
}()
private let queue: DispatchQueue
private let queueKey = DispatchSpecificKey<Void>()
private let output: SSHPTYChannel.OutputHandler
private let exit: SSHPTYChannel.ExitHandler
private let shellIntegrationHandler: SSHPTYChannel.ShellIntegrationHandler
private let logger: Logging.Logger
private let maxSessionsPolicy: SSHMaxSessionsPolicy
private var connection: SSHAuthenticatedConnection?
private var ptyChannel: OpaquePointer?
private var openingPTY: OpaquePointer?
private var retainsControlConnection = false
private var hostID: Host.ID?
private var charset: RemoteCharset = .utf8
private var accessCheck: HostOperationAccessCheck?
private var pumpTimer: (any DispatchSourceTimer)?
private var buffer: SSHConnectionBuffer?
private var channelBudget: SSHChannelBudget?
private var ptyChannelLease: SSHChannelLease?
private var socketEvents: SSHSocketEvents?
private let pendingWrites = SSHByteQueue(chunkSize: 32_700)
private var ptyResize = SSHPTYResizeState()
private var ptyStartupSizing = SSHPTYStartupSizing()
private var terminalActivation = SSHPTYActivationState()
private var activationTask: (request: SSHPTYActivationState.Request, task: Task<Void, Never>)?
private var shellIntegration: RemoteShellIntegrationBootstrap?
private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger
private var commandEventFilter: RemoteCommandEventFilter?
private var contextFilter: RemoteTerminalContextFilter?
private var ordinaryStartup: RemoteShellStartupReadiness?
private var ordinaryStartupInputRevision: UInt64?
private var ordinaryStartupTicket: SSHOrdinaryStartupTicket?
private var runtimeTask: Task<Void, Never>?
private var runtimeTerminal: String?
private var runtimeRevision: UInt64 = 0
private var runtimeEnabled = false
private var runtimeAttachment: RemoteSessionReference?
private var runtimeWantsObservation = false
private var runtimeControlUnavailable = false
private var runtimeHelper: String?
private var runtimeInputRevision: UInt64 = 0
private var latestRuntimeUpdate: TerminalRuntimeUpdate?
private var pendingRuntimeDraft: UUID?
private var runtimeDraftBaseline: (TerminalContextIdentity, String)?
private var inputConverter: RemoteCharsetStreamConverter?
private var outputConverter: RemoteCharsetStreamConverter?
private var workingDirectoryFilter: RemoteOSC7CharsetFilter?
private var didReportExit = false
private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner!
private var execChannelOwner: SSHMultiplexedExecChannelOwner!
private var detectedHistorySource: ShellHistorySource?
private var allowsDefaultHistorySource = true
init(
output: @escaping SSHPTYChannel.OutputHandler,
exit: @escaping SSHPTYChannel.ExitHandler,
shellIntegrationHandler: @escaping SSHPTYChannel.ShellIntegrationHandler,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"),
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy(),
queue: DispatchQueue = DispatchQueue(label: "com.akinokaede.termind.ssh.pty-channel")
) {
_ = Self.initialization
self.queue = queue
queue.setSpecific(key: queueKey, value: ())
self.output = output
self.exit = exit
self.shellIntegrationHandler = shellIntegrationHandler
self.logger = logger
shellIntegrationLifecycleLogger = RemoteShellIntegrationLifecycleLogger(logger: logger)
self.maxSessionsPolicy = maxSessionsPolicy
execChannelOwner = SSHMultiplexedExecChannelOwner(
queue: queue, logger: logger, requestsAgentForwarding: false,
context: { [weak self] in
guard let self, let connection = self.connection, let buffer = self.buffer,
let channelBudget = self.channelBudget
else { return nil }
return .init(
connection: connection, buffer: buffer, channelBudget: channelBudget,
charset: self.charset, hostID: self.hostID)
},
schedulePump: { [weak self] in self?.pump() },
connectionFailed: { [weak self] error in
guard let self else { return }
self.reportExit(.failure((error as? TermindSSHError) ?? .disconnected))
self.cleanup()
},
didCloseLastChannel: { [weak self] in
guard let self, !self.retainsControlConnection,
self.ptyChannel == nil, self.sftpChannelOwner.isEmptyOnQueue
else { return }
self.cleanup()
})
sftpChannelOwner = SSHMultiplexedSFTPChannelOwner(
queue: queue,
context: { [weak self] in
guard let self, let connection = self.connection,
let buffer = self.buffer,
let channelBudget = self.channelBudget
else { return nil }
return .init(
connection: connection, buffer: buffer,
channelBudget: channelBudget, charset: self.charset, accessCheck: self.accessCheck)
},
updateEvents: { [weak self] session, buffer in
self?.updateSharedConnectionEvents(session: session, buffer: buffer)
},
didCloseLastChannel: { [weak self] in
guard let self, !self.retainsControlConnection, self.ptyChannel == nil else { return }
self.cleanup()
})
}
deinit {
// The final owner can disappear on either the app executor or a pump callback.
// Retire socket sources before releasing their connection, without reentering the queue.
if DispatchQueue.getSpecific(key: queueKey) != nil {
cleanup()
} else {
queue.sync { cleanup() }
}
}
public static func openAuthenticatedConnection(
using request: SSHConnectionRequest,
timeout: TimeInterval,
progress: SSHRouteProgressHandler? = nil,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
) async throws -> SSHAuthenticatedConnection {
let operationID = UUID().uuidString.lowercased()
var logger = logger
logger[metadataKey: "operation_id"] = "\(operationID)"
logger[metadataKey: "host_id"] = RemoteLogging.hostID(request.configuration.hostID)
let authenticationLogger = logger
let request = request.enforcingInteractionPolicy()
defer { request.configuration.clearSecrets() }
try Task.checkCancellation()
try request.configuration.accessCheck?()
await request.configuration.beforeConnect?()
try Task.checkCancellation()
var resolvedConfiguration = request.configuration
if let nodeID = resolvedConfiguration.tailscaleNodeID {
guard let network = resolvedConfiguration.network, resolvedConfiguration.jumps.isEmpty else {
throw TailscaleConfigurationError.unavailable
}
resolvedConfiguration.expectedHostKeys = try await network.hostKeys(stableNodeID: nodeID)
guard !resolvedConfiguration.expectedHostKeys.isEmpty else { throw TailscaleConfigurationError.unavailable }
}
let configuration = resolvedConfiguration
logger.info(
"ssh.authentication.started",
metadata: [
"host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)",
"protocol": "ssh", "stage": "opening",
])
let transport = try await SSHRoutedSocket.open(
host: configuration.host, port: configuration.port,
proxy: configuration.proxy, jumps: configuration.jumps,
timeout: timeout, progress: progress,
authorizeHostKey: request.authorizeHostKey,
network: configuration.network,
keyboardInteractive: request.keyboardInteractive,
securityKeySigning: request.securityKeySigning,
interactionPolicy: request.interactionPolicy,
hostKeyConstraints: request.hostKeyConstraints, logger: logger
)
let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.authenticated-opening")
let exchange = SSHKeyboardInteractiveExchange(
configuration: configuration, handler: request.keyboardInteractive,
securityKeySigning: request.securityKeySigning, allowsUserInteraction: request.interactionPolicy == .allowed
)
let state = SSHAuthenticatedOpeningState(transport: transport)
return try await withTaskCancellationHandler {
do {
progress?(.connecting(configuration.hostID))
let observed = try await queue.perform {
try state.begin(
configuration: configuration, timeout: timeout
)
}
let requiredApproval = !configuration.expectedHostKeys.contains(observed.identity)
let authentication: String
if configuration.tailscaleNodeID != nil, configuration.network != nil {
guard configuration.expectedHostKeys.contains(observed.identity) else {
throw TailscaleConfigurationError.hostKeyMismatch
}
progress?(.authenticating(configuration.hostID))
try await queue.perform {
try state.authenticateTailscale(username: configuration.username, exchange: exchange)
}
authentication = "tailscale"
} else {
try await authorizeHostKey(
hostID: configuration.hostID, observed: observed,
expected: configuration.expectedHostKeys,
constraint: request.hostKeyConstraints[configuration.hostID],
authorizer: request.authorizeHostKey
)
try Task.checkCancellation()
authentication = try await queue.perform {
try state.authenticate(
configuration: configuration,
afterHostKeyApproval: requiredApproval,
progress: progress, keyboardInteractive: exchange, logger: authenticationLogger
)
}
}
await exchange.waitForTailscaleApprovalDismissal()
try Task.checkCancellation()
progress?(.authenticated(configuration.hostID))
let result = try await queue.perform { try state.takeResult() }
logger.info(
"ssh.authentication.completed",
metadata: [
"authentication": "\(authentication)",
"host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)",
"outcome": "success", "protocol": "ssh", "stage": "authenticated",
])
return result
} catch {
await exchange.waitForTailscaleApprovalDismissal()
logger.error(
"ssh.authentication.failed",
metadata: [
"error_code": RemoteLogging.errorCode(error),
"host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)",
"outcome": "failure", "protocol": "ssh", "stage": "authentication",
])
await queue.perform { state.cleanup() }
throw error
}
} onCancel: {
exchange.cancel()
state.cancel()
queue.async { state.cleanup() }
}
}
static func installAuthorizedKeys(
_ keys: [SSHAuthorizedKeyInstall],
using request: SSHConnectionRequest,
connection: any SSHConnectionProtocol
) async throws {
guard !keys.isEmpty else { return }
let inputs = try keys.map {
try authorizedKeyInstallInput(publicKey: $0.publicKey, keyName: $0.name)
}
do {
try await connection.connect(using: request)
let command =
#"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"#
for input in inputs {
let result = try await connection.execute(
command, workingDirectory: nil, stdin: .plain(input), timeout: 20,
outputLimit: 64 * 1024, onOutput: nil)
guard result.exitCode == 0 else {
let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
throw TermindSSHError.library(
detail.isEmpty
? "The remote authorized_keys update exited with status \(result.exitCode)."
: "The remote authorized_keys update failed: \(detail)")
}
}
await connection.disconnect()
} catch {
await connection.disconnect()
throw error
}
}
public static func authorizedKeyInstallInput(publicKey: String, keyName: String) throws -> Data {
let normalizedKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard
!normalizedKey.isEmpty, !normalizedKey.contains("\n"),
!normalizedKey.contains("\r")
else {
throw TermindSSHError.library("The public key is not a single OpenSSH key line.")
}
let printableName = keyName.unicodeScalars
.map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) }
.joined()
let normalizedName =
printableName
.split(whereSeparator: \Character.isWhitespace)
.joined(separator: " ")
let safeName = normalizedName.isEmpty ? "Key" : normalizedName
return Data(("# \(safeName) Key by Termind\n\(normalizedKey)\n").utf8)
}
public func connect(
using request: SSHConnectionRequest,
environmentVariables: [HostEnvironmentVariable],
environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler,
startupPreparation: SSHPTYChannel.StartupPreparation? = nil,
shellIntegrationEnabled: Bool,
columns: UInt16, rows: UInt16,
progress: SSHRouteProgressHandler?
) async throws {
let generation = try await queue.perform { [self] in
// Preserve a viewport received before this worker's first connect,
// but never carry another connection's pending request forward.
var nextResize = ptyResize
let generation = nextResize.beginConnection(columns: columns, rows: rows)
let wasActive = terminalActivation.isActive
cleanup()
ptyResize = nextResize
terminalActivation.setActive(wasActive)
return generation
}
let encodedEnvironment = try environmentVariables.map { variable in
(
variable.variable,
try RemoteCharsetStreamConverter.encode(variable.variable, charset: request.configuration.charset),
try RemoteCharsetStreamConverter.encode(variable.value, charset: request.configuration.charset)
)
}
do {
if startupPreparation != nil || shellIntegrationEnabled {
try await connectPrepared(
request: request, generation: generation, environment: encodedEnvironment,
environmentRejection: environmentRejection, shellIntegrationEnabled: shellIntegrationEnabled,
preparation: startupPreparation ?? { _ in nil }, progress: progress)
return
}
let channelBudget = maxSessionsPolicy.makeBudget()
let ptyChannelLease = try await channelBudget.acquire()
let openedConnection = try await Self.openAuthenticatedConnection(
using: request, timeout: 20, progress: progress, logger: logger
)
try await queue.perform { [self] in
guard ptyResize.generation == generation, let size = ptyResize.requestedSize else {
ptyChannelLease.release()
channelBudget.close()
openedConnection.close()
throw CancellationError()
}
connection = openedConnection
hostID = request.configuration.hostID
charset = request.configuration.charset
accessCheck = request.configuration.accessCheck
inputConverter = try RemoteCharsetStreamConverter(
charset: request.configuration.charset, direction: .encoding)
outputConverter = try RemoteCharsetStreamConverter(
charset: request.configuration.charset, direction: .decoding)
workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: request.configuration.charset)
self.channelBudget = channelBudget
self.ptyChannelLease = ptyChannelLease
let created = openedConnection.libssh2Session
guard
let openedChannel = libssh2_channel_open_ex(
created, "session", 7,
2 * 1024 * 1024,
32 * 1024, nil, 0
)
else {
throw Self.connectionError(created, prefix: "Unable to open SSH PTY Channel")
}
ptyChannel = openedChannel
if let provider = request.agentForwarding {
let transport = openedConnection.installBuffer(deferIO: true)
transport.agentChannels = SSHAgentChannels(provider: provider, queue: queue) { [weak self] in
self?.pump()
}
libssh2_session_callback_set2(
created, LIBSSH2_CALLBACK_AUTHAGENT,
unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self))
guard libssh2_channel_request_auth_agent(openedChannel) == 0 else {
throw Self.connectionError(created, prefix: "Agent Forwarding request failed")
}
}
let pty = libssh2_channel_request_pty_ex(
openedChannel, "xterm-256color", 14, nil, 0,
Int32(size.columns), Int32(size.rows),
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)
)
guard pty == 0 else { throw Self.connectionError(created, prefix: "PTY request failed") }
ptyResize.didCreatePTY(size: size)
var rejectedVariables: [String] = []
for environmentVariable in encodedEnvironment {
let result = environmentVariable.1.withUnsafeBytes { variable in
environmentVariable.2.withUnsafeBytes { value in
libssh2_channel_setenv_ex(
openedChannel,
variable.baseAddress?.assumingMemoryBound(to: CChar.self),
UInt32(environmentVariable.1.count),
value.baseAddress?.assumingMemoryBound(to: CChar.self),
UInt32(environmentVariable.2.count))
}
}
if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED {
rejectedVariables.append(environmentVariable.0)
} else if result != 0 {
throw Self.connectionError(created, prefix: "SSH environment request failed")
}
}
if !rejectedVariables.isEmpty { environmentRejection(rejectedVariables) }
guard libssh2_channel_process_startup(openedChannel, "shell", 5, nil, 0) == 0 else {
throw Self.connectionError(created, prefix: "Remote shell request failed")
}
buffer = openedConnection.installBuffer()
libssh2_session_set_blocking(created, 0)
libssh2_keepalive_config(created, 1, 20)
didReportExit = false
shellIntegrationLifecycleLogger.begin(
hostID: request.configuration.hostID,
enabled: shellIntegrationEnabled
)
startPump()
progress?(.connected(request.configuration.hostID))
}
try Task.checkCancellation()
} catch {
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
cleanup()
}
throw error
}
}
public func write(_ data: Data) {
guard !data.isEmpty else { return }
queue.async { [self] in
runtimeRevision &+= 1
runtimeInputRevision &+= 1
terminalActivation.noteInput()
cancelStaleActivation()
runtimeDraftBaseline = nil
// Input expires in-flight operations, but does not itself prove a context switch.
// Keep the last observation until inspection replaces it; operations revalidate it.
if var integration = shellIntegration {
integration.noteUserInput()
shellIntegration = integration
}
let converted = (try? inputConverter?.convert(data).data) ?? data
if !converted.isEmpty { pendingWrites.append(converted) }
pump()
}
}
func submitInitialCommand(_ data: Data, ticket: SSHOrdinaryStartupTicket) async {
do {
let (terminal, revision) = try await queue.perform { [self] in
guard ordinaryStartupTicket == ticket,
ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
runtimeAttachment == nil, ptyChannel != nil, let runtimeTerminal
else { throw TerminalRuntimeError.staleContext }
ordinaryStartupTicket = nil
return (runtimeTerminal, runtimeRevision)
}
let probe = RemoteTerminalProcessProbe(
execute: try await sessionCommandExecutor(runtime: true, expectedInput: ticket.inputRevision))
guard try await probe.isOrdinaryShell(terminal: terminal) else { throw TerminalRuntimeError.unsupported }
try await queue.perform { [self] in
guard ptyResize.generation == ticket.generation, runtimeInputRevision == ticket.inputRevision,
runtimeRevision == revision, runtimeTerminal == terminal, runtimeAttachment == nil,
ptyChannel != nil, latestRuntimeUpdate?.status != .multiplexer,
latestRuntimeUpdate?.backend == nil
else { throw TerminalRuntimeError.staleContext }
runtimeInputRevision &+= 1
runtimeRevision &+= 1
let converted = (try? inputConverter?.convert(data).data) ?? data
if !converted.isEmpty { pendingWrites.append(converted) }
pump()
}
} catch {
logger.notice(
"ssh.initial_command.skipped",
metadata: ["reason": "ordinary_shell_not_verified"])
}
}
public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) {
guard columns > 0, rows > 0 else { return }
queue.async { [self] in
let previous = ptyResize.requestedSize
ptyResize.request(
SSHPTYSize(columns: columns, rows: rows, widthPixels: widthPixels, heightPixels: heightPixels)
)
if ptyResize.requestedSize != previous {
terminalActivation.viewportChanged()
cancelStaleActivation()
}
pump()
}
}
func setTerminalActive(_ active: Bool) {
queue.async { [self] in
terminalActivation.setActive(active)
cancelStaleActivation()
pump()
}
}
public func disconnect() async { await queue.perform { self.closeTerminalSession() } }
func sessionCommandExecutor(
runtime: Bool = false, expectedInput: UInt64? = nil,
activation: SSHPTYActivationState.Request? = nil
) async throws -> RemoteSessionCommandExecutor {
let generation = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
return ptyResize.generation
}
return { [self] command in
let cancellation = TransferCancellationToken()
let timeout: TimeInterval = runtime ? 2 : 15
let deadline = ContinuousClock.now.advanced(by: .seconds(timeout))
let timer = Task {
try await Task.sleep(until: deadline, clock: .continuous)
cancellation.cancel()
}
defer { timer.cancel() }
return try await withTaskCancellationHandler {
try await queue.perform { [self] in
guard connection != nil, ptyResize.generation == generation else { throw CancellationError() }
}
let result: SSHExecResult
do {
result = try await execChannelOwner.execute(
command, workingDirectory: nil, stdin: nil, timeout: timeout, outputLimit: 1024 * 1024,
cancellation: cancellation, onOutput: nil, usesLoginShell: false, retriesChannelOpen: !runtime,
requiresImmediateCapacity: runtime,
validateContext: { [self] in
ptyResize.generation == generation
&& (expectedInput == nil || runtimeInputRevision == expectedInput)
&& (activation.map {
terminalActivation.isCurrent($0) && ptyResize.isSynchronized && ptyChannel != nil
} ?? true)
})
} catch {
try Task.checkCancellation()
if runtime, case SSHCommandExecutionError.channelUnavailable = error {
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
runtimeControlUnavailable = true
publishRuntime(latestRuntimeUpdate ?? .init(status: .unavailable))
}
}
if ContinuousClock.now >= deadline { throw RemoteSessionFailure.timedOut }
throw error
}
try await queue.perform { [self] in
guard connection != nil, ptyResize.generation == generation else { throw CancellationError() }
}
return RemoteSessionCommandResult(
stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode,
truncated: result.stdoutTruncated || result.stderrTruncated || result.signal != nil)
} onCancel: {
cancellation.cancel()
}
}
}
private func connectPrepared(
request: SSHConnectionRequest, generation: UUID, environment: [(String, Data, Data)],
environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler,
shellIntegrationEnabled: Bool, preparation: SSHPTYChannel.StartupPreparation,
progress: SSHRouteProgressHandler?
) async throws {
let opened = try await Self.openAuthenticatedConnection(
using: request, timeout: 20, progress: progress, logger: logger)
try await queue.perform { [self] in
guard ptyResize.generation == generation else {
opened.close()
throw CancellationError()
}
connection = opened
retainsControlConnection = true
hostID = request.configuration.hostID
charset = request.configuration.charset
accessCheck = request.configuration.accessCheck
channelBudget = maxSessionsPolicy.makeBudget()
buffer = opened.installBuffer()
if let provider = request.agentForwarding {
buffer?.agentChannels = SSHAgentChannels(provider: provider, queue: queue) { [weak self] in self?.pump()
}
libssh2_session_callback_set2(
opened.libssh2Session, LIBSSH2_CALLBACK_AUTHAGENT,
unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self))
}
libssh2_session_set_blocking(opened.libssh2Session, 0)
libssh2_keepalive_config(opened.libssh2Session, 1, 20)
startPump()
}
let executor = try await sessionCommandExecutor()
let launch = try await preparation(executor)
try Task.checkCancellation()
try await queue.perform { [self] in
guard ptyResize.generation == generation else { throw CancellationError() }
runtimeAttachment = launch?.runtimeAttachment
runtimeHelper = try RemoteMultiplexerRuntimeAdapter.helper(for: runtimeAttachment)
}
if shellIntegrationEnabled {
var probe = RemoteShellIntegrationBootstrap(charset: charset)
if let result = try? await executor(String(decoding: probe.startCommand, as: UTF8.self)),
result.exitCode == 0, !result.truncated,
let info = probe.parseSystemInfo(in: Data(result.stdout.utf8))
{
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
shellIntegrationHandler(.systemInfo(info))
}
}
}
let runtimeToken = UUID()
var usesRuntime = shellIntegrationEnabled
var startupCommand = launch?.command
if launch == nil, shellIntegrationEnabled {
do {
let install = try RemoteShellStartup.install(
token: runtimeToken, startupCommand: nil, shellIntegration: true, ordinary: true)
let result = try await executor(install + "\nprintf '%s\\n' \"$helper\"\n")
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.unsupported }
let helper = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)
guard helper.hasPrefix("/tmp/termind-shell."),
!helper.dropFirst("/tmp/termind-shell.".count).isEmpty,
helper.dropFirst("/tmp/termind-shell.".count).utf8.allSatisfy({
(48...57).contains($0) || (65...90).contains($0) || (97...122).contains($0)
})
else { throw TerminalRuntimeError.invalidResponse }
startupCommand = try RemoteShellStartup.ordinaryCommand(helper: helper)
try await queue.perform {
guard self.ptyResize.generation == generation else { throw CancellationError() }
self.runtimeHelper = helper
}
} catch {
try Task.checkCancellation()
// Startup-only integration is optional; never fall back to typing a bootstrap.
usesRuntime = false
startupCommand = "exec \"${SHELL:-/bin/sh}\" -l"
}
}
if let original = startupCommand, shellIntegrationEnabled {
startupCommand =
"printf '\\033]777;TermindTerminal=\(runtimeToken.uuidString);%s\\007' \"$(tty)\";\n" + original
}
let command = try startupCommand.map {
SSHPTYStartupBytes(Data(("/bin/sh -c " + (try RemoteMultiplexerCommands.quote($0))).utf8))
}
let runtimeIsEnabled = usesRuntime
try await queue.perform { [self] in
guard ptyResize.generation == generation else { throw CancellationError() }
runtimeEnabled = runtimeIsEnabled
allowsDefaultHistorySource = launch == nil && !runtimeIsEnabled
runtimeWantsObservation = runtimeIsEnabled && launch != nil
if shellIntegrationEnabled {
contextFilter = .init(token: runtimeToken.uuidString, charset: charset)
}
if runtimeIsEnabled {
publishRuntime(.init(status: .unknown))
} else if shellIntegrationEnabled {
publishRuntime(.init(status: .unavailable))
}
}
let variables = environment.map { ($0.0, SSHPTYStartupBytes($0.1), SSHPTYStartupBytes($0.2)) }
guard let budget = try await queue.perform({ self.channelBudget }) else { throw TermindSSHError.disconnected }
let lease = try await budget.acquire()
try await queue.perform { [self] in
guard ptyResize.generation == generation, connection === opened else {
lease.release()
throw CancellationError()
}
ptyChannelLease = lease
}
// Each libssh2 retry stays queue-confined, while selection and network waits never block the pump.
for operation in 0..<(4 + environment.count) {
try await retryPTYOperation(generation: generation, retriesChannelOpen: operation == 0) { [self] in
let session = opened.libssh2Session
switch operation {
case 0:
openingPTY = libssh2_channel_open_ex(session, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0)
return openingPTY != nil ? 0 : libssh2_session_last_errno(session)
case 1:
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
return request.agentForwarding == nil ? 0 : libssh2_channel_request_auth_agent(channel)
case 2:
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
return ptyStartupSizing.requestPTY(resize: &ptyResize) { size in
libssh2_channel_request_pty_ex(
channel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows),
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels))
}
case 3..<(3 + environment.count):
let variable = variables[operation - 3]
let result = libssh2_channel_setenv_ex(
openingPTY, variable.1.pointer, variable.1.count, variable.2.pointer, variable.2.count)
if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED {
environmentRejection([variable.0])
return 0
}
return result
default:
guard let channel = openingPTY else { return LIBSSH2_ERROR_CHANNEL_CLOSED }
return ptyStartupSizing.startProcess(
resize: &ptyResize,
sendResize: { size in
libssh2_channel_request_pty_size_ex(
channel, Int32(size.columns), Int32(size.rows),
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels))
},
start: {
if let command {
return libssh2_channel_process_startup(
channel, "exec", 4, command.pointer, command.count)
}
return libssh2_channel_process_startup(channel, "shell", 5, nil, 0)
})
}
}
}
try await queue.perform { [self] in
guard ptyResize.generation == generation, connection === opened else { throw CancellationError() }
ptyChannel = openingPTY
openingPTY = nil
retainsControlConnection = launch != nil
inputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .encoding)
outputConverter = try RemoteCharsetStreamConverter(charset: charset, direction: .decoding)
workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: charset)
didReportExit = false
if launch == nil, shellIntegrationEnabled {
ordinaryStartup = .init(generation: generation)
ordinaryStartupInputRevision = runtimeInputRevision
}
progress?(.connected(request.configuration.hostID))
pump()
}
}
private func retryPTYOperation(
generation: UUID?, retriesChannelOpen: Bool = false, operation: @escaping @Sendable () -> Int32
) async throws {
let deadline = ContinuousClock.now.advanced(by: .seconds(20))
while true {
try Task.checkCancellation()
let result = try await queue.perform { [self] in
guard connection != nil, generation == nil || ptyResize.generation == generation else {
throw CancellationError()
}
return operation()
}
if result == 0 { return }
guard result == LIBSSH2_ERROR_EAGAIN || (retriesChannelOpen && result == LIBSSH2_ERROR_CHANNEL_FAILURE)
else {
throw TermindSSHError.library("SSH terminal request failed (\(result)).")
}
guard ContinuousClock.now < deadline else {
throw TermindSSHError.library("SSH terminal request timed out.")
}
try await Task.sleep(for: .milliseconds(10))
}
}
func releaseTerminalForControl(expectedGeneration: UUID? = nil) async throws {
let generation = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
guard expectedGeneration == nil || expectedGeneration == ptyResize.generation else {
throw CancellationError()
}
stopRuntimeObservation()
retainsControlConnection = true
didReportExit = true
if let ptyChannel {
openingPTY = ptyChannel
self.ptyChannel = nil
}
pendingWrites.removeAll()
return ptyResize.generation
}
try await retryPTYOperation(generation: generation) { [self] in
guard let openingPTY else { return 0 }
return libssh2_channel_close(openingPTY)
}
try await retryPTYOperation(generation: generation) { [self] in
guard let openingPTY else { return 0 }
guard libssh2_channel_eof(openingPTY) == 1 else { return 0 }
return libssh2_channel_wait_closed(openingPTY)
}
try await retryPTYOperation(generation: generation) { [self] in
guard let openingPTY else { return 0 }
let result = libssh2_channel_free(openingPTY)
if result == 0 { self.openingPTY = nil }
return result
}
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
ptyChannelLease?.release()
ptyChannelLease = nil
}
}
public static func authenticate(
username: String, credentials: [SSHCredentials], session: OpaquePointer,
keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
) throws -> String {
var methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive)
// Asking for the method list sends SSH's `none` authentication request.
// Servers that need no credential accept it there and report the
// session as authenticated without another userauth call.
if libssh2_userauth_authenticated(session) == 1 { return "none" }
var attempted = false
var lastMethod = "none"
var failures: [String] = []
var remaining = credentials
var attemptedInteractive = false
var skippedInteractiveCredential = false
while true {
guard
let index = remaining.firstIndex(where: { credential in
switch credential {
case .password: methods.contains("password")
case .privateKey, .securityKey, .secureEnclave, .interactionRequired:
methods.contains("publickey")
}
})
else {
guard !attemptedInteractive, methods.contains("keyboard-interactive"), let keyboardInteractive else {
break
}
attempted = true
attemptedInteractive = true
lastMethod = "keyboard_interactive"
let result = Self.keyboardInteractive(
username: username, exchange: keyboardInteractive, session: session, socket: socket
)
try keyboardInteractive.checkCancellation()
if let error = keyboardInteractive.error { throw error }
if result == 0 { return lastMethod }
failures.append(connectionErrorDetail(session, fallbackCode: result))
methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive)
if libssh2_userauth_authenticated(session) == 1 { return lastMethod }
continue
}
try keyboardInteractive?.checkCancellation()
let credential = remaining.remove(at: index)
switch credential {
case .password(let password):
if methods.contains("password") {
attempted = true
lastMethod = "password"
let result = try username.withCString { user in
try password.withUnsafeCStringAndConsume { password, passwordLength in
libssh2_userauth_password_ex(
session, user, UInt32(username.utf8.count),
password, UInt32(passwordLength), nil
)
}
}
if result == 0 { return lastMethod }
failures.append(connectionErrorDetail(session, fallbackCode: result))
}
case .privateKey(let privateKey, let publicKey, let passphrase, _):
guard methods.contains("publickey") else { continue }
attempted = true
lastMethod = "public_key"
let result = try username.withCString { user in
try privateKey.withUnsafeCStringAndConsume { privateBuffer, privateKeyLength in
let call: (UnsafePointer<CChar>?) throws -> Int32 = { publicBuffer in
if let passphrase {
return try passphrase.withUnsafeCStringAndConsume { passphraseBuffer, _ in
libssh2_userauth_publickey_frommemory(
session, user, username.utf8.count,
publicBuffer, publicKey?.utf8.count ?? 0,
privateBuffer, privateKeyLength, passphraseBuffer
)
}
}
return libssh2_userauth_publickey_frommemory(
session, user, username.utf8.count,
publicBuffer, publicKey?.utf8.count ?? 0,
privateBuffer, privateKeyLength, nil
)
}
return try publicKey?.withCString(call) ?? call(nil)
}
}
if result == 0 { return lastMethod }
failures.append(connectionErrorDetail(session, fallbackCode: result))
case .securityKey(let credential, let name):
guard let keyboardInteractive else { throw SSHInteractionRequired() }
attempted = true
lastMethod = "security_key"
let publicBlob = try WebAuthnSSH.publicBlob(credential)
let signer = SecurityKeySigner(
credential: credential, keyName: name, exchange: keyboardInteractive, logger: logger)
logger.info("ssh.security_key.started", metadata: ["stage": "public_key_offer"])
var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque()
let timeout = libssh2_session_get_timeout(session)
defer { libssh2_session_set_timeout(session, timeout) }
let result = username.withCString { user in
publicBlob.withUnsafeBytes { bytes in
libssh2_userauth_publickey_signed(
session, user,
bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), bytes.count,
securityKeySignCallback, &abstract)
}
}
try keyboardInteractive.checkCancellation()
if result != 0 {
var metadata: Logging.Logger.Metadata = [
"stage": "\(signer.stage)", "libssh2_code": "\(result)",
"error_code": signer.error.map(RemoteLogging.errorCode) ?? "libssh2_authentication_failed",
]
if let error = signer.error as NSError? {
metadata["native_error_domain"] = "\(error.domain)"
metadata["native_error_code"] = "\(error.code)"
}
logger.error("ssh.security_key.failed", metadata: metadata)
} else {
logger.info("ssh.security_key.completed", metadata: ["stage": "authenticated"])
}
if let error = signer.error { throw error }
if result == 0 { return lastMethod }
if result == LIBSSH2_ERROR_AUTHENTICATION_FAILED, signer.stage == "public_key_offer" {
failures.append(
connectionErrorMessage(
session,
prefix: String(
localized:
"The server rejected the username or public key. Check the login user and the key in authorized_keys."
), fallbackCode: result))
} else {
failures.append(connectionErrorDetail(session, fallbackCode: result))
}
case .secureEnclave(let credential, let publicKey, _):
guard methods.contains("publickey") else { continue }
guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else {
failures.append(
"The Secure Enclave key has an invalid SSH public key."
)
continue
}
attempted = true
lastMethod = "secure_enclave"
let signer = SecureEnclaveSigner(credential: credential)
var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque()
let result = username.withCString { user in
publicBlob.withUnsafeBytes { bytes in
libssh2_userauth_publickey(
session,
user,
bytes.baseAddress?.assumingMemoryBound(to: UInt8.self),
bytes.count,
secureEnclaveSignCallback,
&abstract
)
}
}
// A cancelled/failed system signing prompt must never fall through to another credential.
if let error = signer.error { throw error }
if result == 0 { return lastMethod }
failures.append(connectionErrorDetail(session, fallbackCode: result))
case .interactionRequired:
skippedInteractiveCredential = true
}
methods = try authenticationMethods(username: username, session: session, exchange: keyboardInteractive)
if libssh2_userauth_authenticated(session) == 1 { return lastMethod }
}
if skippedInteractiveCredential { throw SSHInteractionRequired() }
if failures.isEmpty && !attempted {
let available = methods.sorted().joined(separator: ", ")
throw TermindSSHError.authenticationFailed(
connectionErrorMessage(
session, prefix: "No configured credential matches the server methods (\(available))"))
}
// Shared catalog keys owned by AppStrings.Connections; this layer cannot import Presentation.
let details =
failures.isEmpty
? connectionErrorDetail(session)
: failures.joined(separator: String(localized: "; "))
throw TermindSSHError.authenticationFailed(String(localized: "SSH authentication failed: \(details)"))
}
public static func authenticate(
username: String, credentials: [SSHCredentials],
session: OpaquePointer, afterHostKeyApproval: Bool,
keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1,
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
) throws -> String {
do {
return try authenticate(
username: username, credentials: credentials,
session: session, keyboardInteractive: keyboardInteractive, socket: socket, logger: logger
)
} catch {
if error is CancellationError || error is SSHInteractionRequired
|| error is TailscaleConfigurationError || keyboardInteractive?.error != nil
{
throw error
}
let code = libssh2_session_last_errno(session)
if afterHostKeyApproval,
code == LIBSSH2_ERROR_SOCKET_DISCONNECT
|| code == LIBSSH2_ERROR_SOCKET_RECV
|| code == LIBSSH2_ERROR_TIMEOUT
|| code == LIBSSH2_ERROR_SOCKET_TIMEOUT
{
throw TermindSSHError.library(
String(
localized:
"The server closed the SSH connection while waiting for host-key approval. Retry to connect using the accepted key."
) + " [libssh2 \(code)]"
)
}
throw error
}
}
private static func authenticationMethods(
username: String, session: OpaquePointer, exchange: SSHKeyboardInteractiveExchange?
) throws -> Set<String> {
let banner = libssh2_session_banner_get(session).map { String(cString: $0) }
if SSHTailscaleAuthentication.isTailscaleServer(banner) {
switch try SSHTailscaleAuthentication.query(username: username, session: session, exchange: exchange) {
case .authenticated: return []
case .methods(let methods): return methods
case .pending: preconditionFailure("An authentication query must finish before returning.")
}
}
let pointer = username.withCString { user in
libssh2_userauth_list(session, user, UInt32(username.utf8.count))
}
guard let pointer else {
if libssh2_userauth_authenticated(session) == 1 { return [] }
throw Self.connectionError(
session, prefix: "Unable to read SSH authentication methods"
)
}
let bytes = UnsafeBufferPointer(start: pointer, count: strlen(pointer))
.map { UInt8(bitPattern: $0) }
return Set(String(decoding: bytes, as: UTF8.self).split(separator: ",").map(String.init))
}
private static func keyboardInteractive(
username: String, exchange: SSHKeyboardInteractiveExchange, session: OpaquePointer, socket: Int32
) -> Int32 {
let callback = SSHKeyboardInteractiveCallback(exchange: exchange, session: session, socket: socket)
let box = Unmanaged.passRetained(callback)
let abstract = libssh2_session_abstract(session)
let previous = abstract?.pointee
let timeout = libssh2_session_get_timeout(session)
abstract?.pointee = box.toOpaque()
defer {
abstract?.pointee = previous
libssh2_session_set_timeout(session, timeout)
box.release()
}
return username.withCString { user in
libssh2_userauth_keyboard_interactive_ex(
session, user, UInt32(username.utf8.count), sshKeyboardInteractiveResponse
)
}
}
private func startPump() {
pumpTimer?.cancel()
socketEvents?.cancel()
guard let connection else { return }
socketEvents = SSHSocketEvents(socket: connection.byteTransport.socket, queue: queue) {
[weak self] in self?.pump()
}
socketEvents?.update(read: true, write: false)
let timer = DispatchSource.makeTimerSource(queue: queue)
// Timer advances shell-integration deadlines and keepalive only; data
// throughput is readiness-driven by `socketEvents`.
timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(20))
timer.setEventHandler { [weak self] in self?.pump() }
pumpTimer = timer
timer.resume()
}
private func pump() {
guard let connection, let buffer else { return }
let session = connection.libssh2Session
let socket = connection.byteTransport.socket
let deadline = DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds
buffer.beginPass(deadline: deadline)
do { try buffer.flush(socket: socket) } catch {
reportExit(.failure(Self.connectionError(session, prefix: "SSH transport write failed")))
cleanup()
return
}
if buffer.isBackpressured {
socketEvents?.update(read: false, write: true)
return
}
execChannelOwner.wakeAllOnQueue()
let execProgress = execChannelOwner.advanceOnQueue(
deadline: min(
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 4))
guard self.connection != nil else { return }
if execProgress { queue.async { [weak self] in self?.pump() } }
var agentProgress =
buffer.agentChannels?.advance(
deadline: min(
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2),
permitsIO: { !buffer.isBackpressured }) ?? false
if let ptyChannel {
let resizeResult = ptyResize.flush { size in
libssh2_channel_request_pty_size_ex(
ptyChannel, Int32(size.columns), Int32(size.rows),
Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels)
)
}
if resizeResult != 0, resizeResult != LIBSSH2_ERROR_EAGAIN {
var metadata: Logger.Metadata = [
"error_code": "library", "protocol": "ssh", "stage": "pty_resize",
]
if let hostID { metadata["host_id"] = RemoteLogging.hostID(hostID) }
logger.error("ssh.pty_resize.failed", metadata: metadata)
ptyResize.discardFailedRequest()
}
// Continue other I/O on EAGAIN: window-change may itself be waiting
// for a previously blocked channel write or window adjustment.
var buffer = [CChar](repeating: 0, count: 32 * 1024)
while DispatchTime.now().uptimeNanoseconds < deadline {
let count = libssh2_channel_read_ex(ptyChannel, 0, &buffer, buffer.count)
if count > 0 {
deliverOutput(Data(bytes: buffer, count: count))
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
break
} else {
reportExit(.failure(Self.connectionError(session, prefix: "SSH read failed")))
cleanup()
return
}
}
if var integration = shellIntegration {
let update = integration.poll()
shellIntegration = update.isComplete ? nil : integration
applyShellIntegrationUpdate(update)
}
if let generation = ptyResize.generation, ordinaryStartup?.poll(generation: generation) == true {
ordinaryStartupTicket =
runtimeTerminal == nil || runtimeInputRevision != ordinaryStartupInputRevision
? nil : .init(generation: generation, inputRevision: runtimeInputRevision)
shellIntegrationHandler(
.ordinaryStartupUnavailable(ticket: ordinaryStartupTicket))
if ordinaryStartupTicket == nil {
logger.notice(
"ssh.initial_command.skipped",
metadata: ["reason": "terminal_missing_or_input_changed"])
}
}
while !pendingWrites.isEmpty && DispatchTime.now().uptimeNanoseconds < deadline {
let written = pendingWrites.withFirstBytes { bytes -> Int in
guard let address = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 }
return libssh2_channel_write_ex(ptyChannel, 0, address, bytes.count)
}
if written > 0 {
pendingWrites.consume(written)
} else if written == LIBSSH2_ERROR_EAGAIN {
break
} else {
reportExit(.failure(Self.connectionError(session, prefix: "SSH write failed")))
cleanup()
return
}
}
}
agentProgress =
(buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) ?? false)
|| agentProgress
if agentProgress { queue.async { [weak self] in self?.pump() } }
var next = Int32(0)
let keepalive = libssh2_keepalive_send(session, &next)
if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN {
reportExit(.failure(Self.connectionError(session, prefix: "SSH keepalive failed")))
cleanup()
return
}
if let ptyChannel, libssh2_channel_eof(ptyChannel) == 1 {
let status = libssh2_channel_get_exit_status(ptyChannel)
if retainsControlConnection {
// Keep the authenticated transport, but finish CLOSE/FREE before allowing
// a post-exit probe to consume the server's only session slot.
openingPTY = ptyChannel
self.ptyChannel = nil
let generation = ptyResize.generation
Task { [self] in
do {
try await releaseTerminalForControl(expectedGeneration: generation)
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
didReportExit = false
reportExit(.success(status))
}
} catch {
await queue.perform { [self] in
guard ptyResize.generation == generation else { return }
didReportExit = false
reportExit(.failure(.disconnected))
cleanup()
}
}
}
return
}
reportExit(.success(status))
closeTerminalSession(preservingControl: retainsControlConnection)
return
}
do { try buffer.flush(socket: socket) } catch {
reportExit(.failure(Self.connectionError(session, prefix: "SSH transport write failed")))
cleanup()
return
}
let directions = libssh2_session_block_directions(session)
socketEvents?.update(
read: !buffer.isBackpressured,
write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0)
scheduleTerminalActivation()
}
private func cancelStaleActivation() {
guard let activationTask, !terminalActivation.isCurrent(activationTask.request) else { return }
activationTask.task.cancel()
self.activationTask = nil
}
private func scheduleTerminalActivation() {
guard activationTask == nil, ptyChannel != nil, runtimeEnabled, !runtimeControlUnavailable,
channelBudget?.permitsConcurrentControl == true,
let request = terminalActivation.takeRequest(resizeIsSynchronized: ptyResize.isSynchronized)
else { return }
let generation = ptyResize.generation
let inputRevision = runtimeInputRevision
let task = Task { [weak self] in
guard let self else { return }
do {
try Task.checkCancellation()
let execute = try await sessionCommandExecutor(
runtime: true, expectedInput: inputRevision, activation: request)
try await RemoteMultiplexerRuntimeAdapter(execute: execute).refreshSize(of: request.client)
} catch {
// Optional sizing assistance must not disconnect a usable terminal or inject a fallback key.
}
await queue.perform { [self] in
guard ptyResize.generation == generation, activationTask?.request == request else { return }
terminalActivation.finish(request)
activationTask = nil
}
}
activationTask = (request, task)
}
private func reportExit(_ result: Result<Int32, TermindSSHError>) {
guard !didReportExit else { return }
didReportExit = true
exit(result)
}
private func deliverOutput(_ data: Data) {
guard var integration = shellIntegration else {
deliverIntegratedOutput(data)
return
}
let update = integration.receive(data)
shellIntegration = update.isComplete ? nil : integration
applyShellIntegrationUpdate(update)
}
private func applyShellIntegrationUpdate(
_ update: RemoteShellIntegrationBootstrap.Update
) {
if let diagnostic = update.diagnostic, let hostID {
shellIntegrationLifecycleLogger.record(diagnostic, hostID: hostID)
}
if let command = update.command {
pendingWrites.append(command)
}
if let source = update.historySource { detectedHistorySource = source }
if update.integrationInstalled {
shellIntegrationHandler(
.available(
shell: update.shell ?? "unknown", systemInfo: update.systemInfo,
homeDirectory: update.homeDirectory
)
)
} else {
if let systemInfo = update.systemInfo {
shellIntegrationHandler(.systemInfo(systemInfo))
}
if update.homeResolutionUnavailable || update.isComplete {
shellIntegrationHandler(.homeResolutionUnavailable)
}
}
if let visibleOutput = update.visibleOutput, !visibleOutput.isEmpty {
deliverIntegratedOutput(visibleOutput)
}
}
private func deliverIntegratedOutput(_ data: Data) {
if var contextFilter {
let update = contextFilter.receive(data)
self.contextFilter = contextFilter
if let terminal = update.terminal {
runtimeTerminal = terminal
startRuntimeObservation()
}
for context in update.contexts {
runtimeRevision &+= 1
runtimeWantsObservation = RemoteTerminalContextFilter.needsObservation(context)
publishRuntime(context)
if runtimeWantsObservation { startRuntimeObservation() }
}
if !update.output.isEmpty { emitConvertedOutput(update.output) }
return
}
guard var commandEventFilter else {
emitConvertedOutput(data)
return
}
let update = commandEventFilter.receive(data)
self.commandEventFilter = commandEventFilter
if !update.visibleOutput.isEmpty { emitConvertedOutput(update.visibleOutput) }
for event in update.events {
switch event {
case .commandStarted(let command):
shellIntegrationHandler(.commandStarted(command))
case .commandFinished(let status, let duration):
shellIntegrationHandler(.commandFinished(status, duration: duration))
case .promptReady:
shellIntegrationHandler(.promptReady)
case .draft(let draft):
shellIntegrationHandler(.draft(draft))
}
}
}
private func startRuntimeObservation() {
guard runtimeEnabled, runtimeTask == nil, runtimeTerminal != nil else { return }
let generation = ptyResize.generation
runtimeTask = Task { [weak self] in
guard let self else { return }
var delay = 500
while !Task.isCancelled {
try? await Task.sleep(for: .milliseconds(delay))
guard !Task.isCancelled else { break }
let request = try? await queue.perform {
[self] () -> (String, UInt64, RemoteCharset, RemoteSessionReference?)? in
guard runtimeEnabled, runtimeWantsObservation, ptyResize.generation == generation,
!runtimeControlUnavailable, let terminal = runtimeTerminal
else { return nil }
return (terminal, runtimeRevision, charset, runtimeAttachment)
}
guard let request else { continue }
do {
let adapter = RemoteMultiplexerRuntimeAdapter(
attachment: request.3, execute: try await sessionCommandExecutor(runtime: true),
charset: request.2)
let update = try await adapter.inspect(terminal: request.0)
try Task.checkCancellation()
await queue.perform { [self] in
guard ptyResize.generation == generation, runtimeRevision == request.1,
runtimeWantsObservation
else { return }
publishRuntimeObservation(update)
}
delay = update.status == .multiplexer ? 750 : min(delay * 2, 5000)
} catch {
guard !Task.isCancelled else { break }
await queue.perform { [self] in
guard ptyResize.generation == generation, runtimeRevision == request.1,
runtimeWantsObservation
else { return }
publishRuntimeObservation(.init(status: .unavailable))
}
delay = min(delay * 2, 5000)
}
}
}
}
private func publishRuntimeObservation(_ update: TerminalRuntimeUpdate) {
publishRuntime(RemoteTerminalContextFilter.reconcileObservation(update, current: latestRuntimeUpdate))
}
private func publishRuntime(_ proposed: TerminalRuntimeUpdate) {
ordinaryStartup?.receive(proposed)
var update = proposed
if runtimeControlUnavailable || channelBudget?.permitsConcurrentControl == false {
if update.status == .multiplexer {
update = .init(status: .unavailable)
} else {
for index in update.contexts.indices {
update.contexts[index].capabilities.subtract([.readDraft, .replaceDraft])
}
}
}
latestRuntimeUpdate = update
terminalActivation.updateClient(
runtimeTerminal.flatMap { RemoteTmuxClientIdentity(update: update, terminal: $0) })
cancelStaleActivation()
scheduleTerminalActivation()
detectedHistorySource =
update.status == .ordinary || update.status == .multiplexer
? update.contexts.first(where: { $0.identity == update.active })?.historySource : nil
shellIntegrationHandler(.runtime(update))
}
func confirmContext(_ expected: TerminalShellContext) async throws {
let state = try await queue.perform { [self] in
guard let terminal = runtimeTerminal, latestRuntimeUpdate?.active == expected.identity else {
throw TerminalRuntimeError.staleContext
}
return (terminal, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
}
if expected.identity.backend != "shell" {
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.1)
let update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.4, execute: execute, charset: state.3
).inspect(terminal: state.0)
guard update.active == expected.identity,
update.contexts.first(where: { $0.identity == expected.identity }) == expected
else { throw TerminalRuntimeError.staleContext }
}
try Task.checkCancellation()
try await queue.perform { [self] in
guard runtimeInputRevision == state.1, ptyResize.generation == state.2,
latestRuntimeUpdate?.active == expected.identity,
latestRuntimeUpdate?.contexts.first(where: { $0.identity == expected.identity }) == expected
else { throw TerminalRuntimeError.staleContext }
}
}
func contextDraft(_ expected: TerminalShellContext, replacing replacement: String?) async throws -> String {
let request = UUID()
let state = try await queue.perform { [self] in
guard pendingRuntimeDraft == nil, let terminal = runtimeTerminal,
latestRuntimeUpdate?.active == expected.identity, expected.promptReady,
!runtimeControlUnavailable,
latestRuntimeUpdate?.contexts.first(where: { $0.identity == expected.identity })?
.capabilities.contains(replacement == nil ? .readDraft : .replaceDraft) == true
else { throw TerminalRuntimeError.staleContext }
pendingRuntimeDraft = request
return (terminal, runtimeHelper, runtimeInputRevision, ptyResize.generation, charset, runtimeAttachment)
}
do {
let execute = try await sessionCommandExecutor(runtime: true, expectedInput: state.2)
let update: TerminalRuntimeUpdate?
if expected.identity.backend != "shell" {
update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.5, execute: execute, charset: state.4
).inspect(terminal: state.0)
guard update?.active == expected.identity,
update?.contexts.first(where: { $0.identity == expected.identity }) == expected
else { throw TerminalRuntimeError.staleContext }
} else {
update = nil
}
try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3)
var target: RemoteMultiplexerDraftTarget?
if expected.identity.backend != "shell" {
target = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.5, execute: execute, charset: state.4
).draftTarget(context: expected, terminal: state.0, replacing: replacement != nil)
}
let command = try RemoteTerminalDraft.command(
context: expected, terminal: state.0, helper: state.1,
clientIdentity: update?.clientIdentity, request: request, replacement: replacement,
original: try await queue.perform { [self] in
replacement != nil && runtimeDraftBaseline?.0 == expected.identity ? runtimeDraftBaseline?.1 : nil
}, charset: state.4, target: target)
let started = try await execute(command)
guard started.exitCode == 0, !started.truncated else { throw TerminalRuntimeError.staleContext }
if expected.identity.backend == "shell" {
try await queue.perform { [self] in
guard pendingRuntimeDraft == request, runtimeInputRevision == state.2,
ptyResize.generation == state.3, latestRuntimeUpdate?.active == expected.identity
else { throw TerminalRuntimeError.staleContext }
let key = replacement == nil ? "\u{1B}[777;1~" : "\u{1B}[777;2~"
pendingWrites.append(Data(key.utf8))
pump()
}
}
let result = try await execute(
RemoteTerminalDraft.resultCommand(context: expected, helper: state.1, request: request))
guard result.exitCode == 0, !result.truncated else { throw TerminalRuntimeError.staleContext }
let record = try RemoteShellContextRecord(result.stdout, charset: state.4)
guard record.instance == expected.identity.shellInstance,
let value = record.entries.compactMap({ entry -> String? in
if case .draft(let id, let value) = entry.1, id == request.uuidString { return value }
return nil
}).last
else { throw TerminalRuntimeError.staleContext }
try await validateDraft(request, context: expected.identity, input: state.2, generation: state.3)
await queue.perform { [self] in
if pendingRuntimeDraft == request {
pendingRuntimeDraft = nil
runtimeDraftBaseline = (expected.identity, value)
}
}
return value
} catch {
await queue.perform { [self] in if pendingRuntimeDraft == request { pendingRuntimeDraft = nil } }
throw error
}
}
private func validateDraft(
_ request: UUID, context: TerminalContextIdentity, input: UInt64, generation: UUID?
) async throws {
try Task.checkCancellation()
try await queue.perform { [self] in
guard pendingRuntimeDraft == request, runtimeInputRevision == input, ptyResize.generation == generation,
latestRuntimeUpdate?.active == context
else { throw TerminalRuntimeError.staleContext }
}
}
private func emitConvertedOutput(_ data: Data) {
do {
var filteredData = data
if var filter = workingDirectoryFilter {
let filtered = filter.receive(data)
workingDirectoryFilter = filter
filteredData = filtered.data
}
let converted =
try outputConverter?.convert(filteredData)
?? .init(data: filteredData, rejectedInput: false)
if !converted.data.isEmpty { output(converted.data) }
} catch {
output(data)
}
}
private func finishCharsetOutput() {
guard let outputConverter else { return }
do {
if var filter = workingDirectoryFilter {
let remainder = filter.finish()
workingDirectoryFilter = filter
if !remainder.isEmpty {
let prefix = try outputConverter.convert(remainder)
if !prefix.data.isEmpty { output(prefix.data) }
}
}
let converted = try outputConverter.finish()
if !converted.data.isEmpty { output(converted.data) }
} catch {}
}
private func closeTerminalSession(preservingControl: Bool = false) {
stopRuntimeObservation()
retainsControlConnection = preservingControl
execChannelOwner.cancelAllOnQueue()
detectedHistorySource = nil
buffer?.agentChannels?.shutdown()
finishCharsetOutput()
pendingWrites.removeAll()
ptyResize.reset()
ptyStartupSizing = SSHPTYStartupSizing()
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
shellIntegration = nil
commandEventFilter = nil
inputConverter = nil
outputConverter = nil
workingDirectoryFilter = nil
hostID = nil
if let ptyChannel {
_ = libssh2_channel_send_eof(ptyChannel)
_ = libssh2_channel_close(ptyChannel)
_ = libssh2_channel_free(ptyChannel)
self.ptyChannel = nil
ptyChannelLease?.release()
ptyChannelLease = nil
}
// Cancelled exec callers may already have returned while native close still waits
// for the peer. Only live SFTP channels justify keeping this transport running.
if sftpChannelOwner.isEmptyOnQueue && !retainsControlConnection {
cleanup()
} else {
queue.async { [weak self] in self?.pump() }
}
}
private func cleanup() {
stopRuntimeObservation()
retainsControlConnection = false
buffer?.agentChannels?.shutdown()
pumpTimer?.cancel()
pumpTimer = nil
let connection = connection
self.connection = nil
pendingWrites.removeAll()
ptyResize.reset()
ptyStartupSizing = SSHPTYStartupSizing()
if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) }
shellIntegration = nil
commandEventFilter = nil
inputConverter = nil
outputConverter = nil
workingDirectoryFilter = nil
hostID = nil
if let ptyChannel {
_ = libssh2_channel_send_eof(ptyChannel)
_ = libssh2_channel_close(ptyChannel)
_ = libssh2_channel_free(ptyChannel)
self.ptyChannel = nil
}
if let openingPTY {
_ = libssh2_channel_close(openingPTY)
_ = libssh2_channel_free(openingPTY)
self.openingPTY = nil
}
execChannelOwner.closeAllOnQueue(error: CancellationError())
detectedHistorySource = nil
sftpChannelOwner.closeAllOnQueue()
ptyChannelLease?.release()
ptyChannelLease = nil
channelBudget?.close()
channelBudget = nil
buffer = nil
if let socketEvents {
socketEvents.cancel { connection?.close() }
self.socketEvents = nil
} else {
connection?.close()
}
}
private func stopRuntimeObservation() {
ordinaryStartup = nil
ordinaryStartupInputRevision = nil
ordinaryStartupTicket = nil
activationTask?.task.cancel()
activationTask = nil
terminalActivation.reset()
runtimeTask?.cancel()
runtimeTask = nil
runtimeTerminal = nil
runtimeEnabled = false
runtimeAttachment = nil
allowsDefaultHistorySource = true
runtimeWantsObservation = false
runtimeControlUnavailable = false
runtimeRevision &+= 1
contextFilter = nil
latestRuntimeUpdate = nil
runtimeHelper = nil
pendingRuntimeDraft = nil
runtimeDraftBaseline = nil
runtimeInputRevision &+= 1
}
public static func observation(host: String, port: Int, session: OpaquePointer) throws
-> SSHHostKeyObservation
{
guard let hash = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_SHA256)) else {
throw connectionError(session, prefix: "The server did not provide a host-key fingerprint")
}
let digest = Data(bytes: hash, count: 32).base64EncodedString().replacingOccurrences(of: "=", with: "")
let legacyMD5Fingerprint = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_MD5)).map {
Data(bytes: $0, count: 16)
}
var length = 0
var type = Int32(LIBSSH2_HOSTKEY_TYPE_UNKNOWN)
_ = libssh2_session_hostkey(session, &length, &type)
let algorithm: SSHKeyAlgorithm
switch type {
case LIBSSH2_HOSTKEY_TYPE_RSA: algorithm = .rsa
case LIBSSH2_HOSTKEY_TYPE_ECDSA_256: algorithm = .ecdsa256
case LIBSSH2_HOSTKEY_TYPE_ECDSA_384: algorithm = .ecdsa384
case LIBSSH2_HOSTKEY_TYPE_ECDSA_521: algorithm = .ecdsa521
case LIBSSH2_HOSTKEY_TYPE_ED25519: algorithm = .ed25519
default:
throw connectionError(session, prefix: "The server provided an unsupported host-key type")
}
return SSHHostKeyObservation(
hostname: host, port: port, algorithm: algorithm,
fingerprint: "SHA256:\(digest)", legacyMD5Fingerprint: legacyMD5Fingerprint
)
}
public static func validateHostKey(
_ observed: SSHHostKeyObservation, expected: Set<SSHHostKeyIdentity>
) throws {
guard expected.contains(observed.identity) else {
throw TermindSSHError.hostKeyChanged(
expected: Set(expected.map(\.fingerprint)).sorted(),
received: observed.fingerprint
)
}
}
public static func authorizeHostKey(
hostID: Host.ID, observed: SSHHostKeyObservation,
expected: Set<SSHHostKeyIdentity>, constraint: SSHHostKeyConstraint? = nil,
authorizer: SSHHostKeyAuthorizer?
) async throws {
let constraintMatched: Bool
if let constraint {
guard constraint.matches(observed) else { throw TermindSSHError.mismatchedHostKey }
constraintMatched = true
} else {
constraintMatched = false
}
if expected.contains(observed.identity) { return }
// A provisional fingerprint is an additional constraint, never an
// override for an existing, conflicting Known Hosts record.
if constraintMatched, !expected.isEmpty { throw TermindSSHError.mismatchedHostKey }
if let authorizer {
let decision = try await authorizer(
SSHHostKeyChallenge(
hostID: hostID, observation: observed,
expectedHostKeys: expected,
provisionalConstraintMatched: constraintMatched
)
)
guard decision == .accept else { throw CancellationError() }
return
}
if expected.isEmpty { throw TermindSSHError.untrustedHost }
throw TermindSSHError.hostKeyChanged(
expected: expected.map(\.fingerprint).sorted(),
received: observed.fingerprint
)
}
public static func performHandshake(
_ session: OpaquePointer, socket: Int32,
peer: String, timeout: TimeInterval = 20
) throws {
let started = ContinuousClock.now
errno = 0
libssh2_session_set_timeout(
session, max(1, Int(timeout * 1_000))
)
let result = libssh2_session_handshake(session, socket)
let systemError = errno == 0 ? nil : errno
guard result == 0 else {
var message: UnsafeMutablePointer<CChar>?
var length = Int32(0)
_ = libssh2_session_last_error(session, &message, &length, 0)
let detail = message.map { String(cString: $0) } ?? ""
let elapsed = started.duration(to: .now).components
let milliseconds = max(
0,
Int(elapsed.seconds) * 1_000
+ Int(elapsed.attoseconds / 1_000_000_000_000_000)
)
let phase: SSHHandshakeFailure.Phase =
detail.localizedCaseInsensitiveContains("banner")
? .identification
: detail.localizedCaseInsensitiveContains("key")
? .keyExchange : .unknown
let kind: SSHHandshakeFailure.Kind
switch result {
case LIBSSH2_ERROR_SOCKET_DISCONNECT:
kind = .peerClosed
case LIBSSH2_ERROR_BANNER_RECV:
kind = .invalidIdentification
case LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_SOCKET_TIMEOUT:
kind = .timedOut
case LIBSSH2_ERROR_SOCKET_RECV
where systemError == EAGAIN || systemError == EWOULDBLOCK
|| systemError == ETIMEDOUT:
kind = .timedOut
case LIBSSH2_ERROR_SOCKET_RECV:
kind = .receiveFailed
default:
kind = .other
}
throw TermindSSHError.handshake(
SSHHandshakeFailure(
code: result, systemError: systemError, kind: kind,
phase: phase,
elapsedMilliseconds: milliseconds, peer: peer
)
)
}
}
public static func connectionError(_ session: OpaquePointer, prefix: String) -> TermindSSHError {
.library(connectionErrorMessage(session, prefix: prefix))
}
private static func connectionErrorMessage(
_ session: OpaquePointer, prefix: String, fallbackCode: Int32? = nil
) -> String {
"\(prefix): \(connectionErrorDetail(session, fallbackCode: fallbackCode))"
}
private static func connectionErrorDetail(
_ session: OpaquePointer, fallbackCode: Int32? = nil
) -> String {
var message: UnsafeMutablePointer<CChar>?
var length = Int32(0)
let code = libssh2_session_last_error(session, &message, &length, 0)
let detail = message.map { String(cString: $0) }?.trimmingCharacters(in: .whitespacesAndNewlines)
let reason = detail.flatMap { $0.isEmpty ? nil : $0 } ?? "libssh2 error \(fallbackCode ?? code)"
return "\(reason) [libssh2 \(fallbackCode ?? code)]"
}
}
nonisolated extension SSHPTYChannelWorker {
fileprivate func executeHistoryRead(
_ command: String, outputLimit: Int, cancellation: TransferCancellationToken,
context expected: TerminalShellContext?
) async throws -> SSHExecResult {
let generation = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
return ptyResize.generation
}
if let expected { try await confirmHistoryContext(expected) }
let result = try await execChannelOwner.execute(
command, workingDirectory: nil, stdin: nil, timeout: 10,
outputLimit: outputLimit, cancellation: cancellation, onOutput: nil, usesLoginShell: false,
retriesChannelOpen: false, requiresImmediateCapacity: true,
validateContext: { [self] in
ptyResize.generation == generation
&& (expected.map(matchesHistoryContext) ?? allowsDefaultHistorySource)
})
if let expected { try await confirmHistoryContext(expected) }
try Task.checkCancellation()
try await queue.perform { [self] in
guard connection != nil, ptyResize.generation == generation else { throw CancellationError() }
}
return result
}
private func matchesHistoryContext(_ expected: TerminalShellContext) -> Bool {
guard let update = latestRuntimeUpdate,
update.status == .ordinary || update.status == .multiplexer,
update.active == expected.identity, let source = expected.historySource
else { return false }
return update.contexts.first(where: { $0.identity == expected.identity })?.historySource == source
}
private func confirmHistoryContext(_ expected: TerminalShellContext) async throws {
let state = try await queue.perform { [self] in
guard matchesHistoryContext(expected), let terminal = runtimeTerminal else {
throw TerminalRuntimeError.staleContext
}
return (terminal, ptyResize.generation, charset, runtimeAttachment)
}
if expected.identity.backend != "shell" {
let execute = try await sessionCommandExecutor(runtime: true)
let update = try await RemoteMultiplexerRuntimeAdapter(
attachment: state.3, execute: execute, charset: state.2
)
.inspect(terminal: state.0)
guard update.status == .multiplexer, update.active == expected.identity,
update.contexts.first(where: { $0.identity == expected.identity })?.historySource
== expected.historySource
else { throw TerminalRuntimeError.staleContext }
}
try Task.checkCancellation()
try await queue.perform { [self] in
guard ptyResize.generation == state.1, matchesHistoryContext(expected) else {
throw TerminalRuntimeError.staleContext
}
}
}
fileprivate func historySource() async throws -> ShellHistorySource? {
let deadline = ContinuousClock.now.advanced(by: .seconds(6))
while true {
try Task.checkCancellation()
let (source, pending) = try await queue.perform { [self] in
guard connection != nil else { throw TermindSSHError.disconnected }
// Runtime shells must use a context-bound read, even before the UI receives their update.
guard allowsDefaultHistorySource else { throw TerminalRuntimeError.staleContext }
return (detectedHistorySource, shellIntegration != nil)
}
if !pending || source != nil || ContinuousClock.now >= deadline { return source }
try await Task.sleep(for: .milliseconds(50))
}
}
fileprivate func openSFTPChannel() async throws -> any SFTPChannelProtocol {
try await sftpChannelOwner.openChannel(retaining: self)
}
private func updateSharedConnectionEvents(session: OpaquePointer, buffer: SSHConnectionBuffer) {
let directions = libssh2_session_block_directions(session)
socketEvents?.update(
read: !buffer.isBackpressured,
write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0)
}
}
private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendable {
private let cancellationLock = NSLock()
private var cancellableSocket: Int32
private var transport: SSHRoutedSocket?
private var session: OpaquePointer?
init(transport: SSHRoutedSocket) {
self.transport = transport
cancellableSocket = transport.socket
}
func begin(
configuration: SSHConnectionConfiguration, timeout: TimeInterval
) throws -> SSHHostKeyObservation {
guard let transport else { throw TermindSSHError.disconnected }
guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.library("Unable to create an SSH session.")
}
session = created
libssh2_session_set_blocking(created, 1)
try SSHConnectionOpener.performHandshake(
created, socket: transport.socket,
peer: SSHSocketConnector.peerDescription(
socket: transport.socket,
fallbackHost: configuration.host,
port: configuration.port
),
timeout: timeout
)
return try SSHConnectionOpener.observation(
host: configuration.host, port: configuration.port,
libssh2Session: created
)
}
func authenticate(
configuration: SSHConnectionConfiguration,
afterHostKeyApproval: Bool,
progress: SSHRouteProgressHandler?, keyboardInteractive: SSHKeyboardInteractiveExchange,
logger: Logging.Logger
) throws -> String {
guard let session else { throw TermindSSHError.disconnected }
progress?(.authenticating(configuration.hostID))
let authentication = try SSHConnectionOpener.authenticate(
username: configuration.username,
credentials: configuration.credentials,
libssh2Session: session,
afterHostKeyApproval: afterHostKeyApproval,
keyboardInteractive: keyboardInteractive, socket: transport?.socket ?? -1, logger: logger
)
return authentication
}
func authenticateTailscale(username: String, exchange: SSHKeyboardInteractiveExchange) throws {
guard let session else { throw TermindSSHError.disconnected }
guard
try SSHTailscaleAuthentication.query(username: username, session: session, exchange: exchange)
== .authenticated
else {
throw SSHTailscaleAuthentication.failure(code: LIBSSH2_ERROR_AUTHENTICATION_FAILED)
}
}
func takeResult() throws -> SSHAuthenticatedConnection {
guard let transport, let session else { throw TermindSSHError.disconnected }
let result = SSHAuthenticatedConnection(
byteTransport: transport, libssh2Session: session)
cancellationLock.withLock { cancellableSocket = -1 }
self.transport = nil
self.session = nil
return result
}
func cleanup() {
if let session {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION,
"Termind connection cancelled", ""
)
_ = libssh2_session_free(session)
self.session = nil
}
cancellationLock.withLock { cancellableSocket = -1 }
transport?.close()
transport = nil
}
func cancel() {
cancellationLock.withLock {
if cancellableSocket >= 0 {
_ = Darwin.shutdown(cancellableSocket, SHUT_RDWR)
}
}
}
}
private nonisolated final class SecureEnclaveSigner: @unchecked Sendable {
public let credential: SecureEnclaveKeyCredential
public var error: (any Error)?
public init(credential: SecureEnclaveKeyCredential) {
self.credential = credential
}
}
private nonisolated let secureEnclaveSignCallback:
@convention(c) (
OpaquePointer?, UnsafeMutablePointer<UnsafeMutablePointer<UInt8>?>?,
UnsafeMutablePointer<Int>?, UnsafePointer<UInt8>?, Int,
UnsafeMutablePointer<UnsafeMutableRawPointer?>?
) -> Int32 = { _, signature, signatureLength, data, dataLength, abstract in
guard let opaqueSigner = abstract?.pointee, let data, dataLength > 0 else { return -1 }
let signer = Unmanaged<SecureEnclaveSigner>.fromOpaque(opaqueSigner).takeUnretainedValue()
do {
let signedData = try SecureEnclaveKey.sign(
Data(bytes: data, count: dataLength), credential: signer.credential
)
guard let output = malloc(signedData.count)?.assumingMemoryBound(to: UInt8.self) else {
return -1
}
signedData.copyBytes(to: output, count: signedData.count)
signature?.pointee = output
signatureLength?.pointee = signedData.count
return 0
} catch {
signer.error = error
return -1
}
}