Add interactive SSH authentication to agent tools

This commit is contained in:
Kaede Akino committed 2026-08-26 11:31:06 +08:00
1 parent 99be1d6885
commit aa136347da
12 files changed
+881 -77

No files matched your search

+16 -14
View File
@@ -36,9 +36,6 @@ struct RootView: View {
TabBarView(pinnedTabStripWidth: pinnedTabStripWidth)
Divider()
splitContent
if window.selectedTab != nil {
StatusBarView()
}
}
.frame(
minWidth: Metrics.Window.detailMin,
@@ -118,18 +115,23 @@ struct RootView: View {
max: Metrics.Sidebar.max
)
} detail: {
detail
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.minHeight,
maxHeight: .infinity
)
.onGeometryChange(for: CGFloat.self) {
$0.frame(in: .named(Self.layoutSpace)).minX
} action: {
recordPinnedTabStripWidth($0)
VStack(spacing: 0) {
detail
if case .workspace = window.selectedTopLevelTab {
StatusBarView()
}
}
.frame(
minWidth: Metrics.Window.detailMin,
maxWidth: .infinity,
minHeight: Metrics.Window.minHeight,
maxHeight: .infinity
)
.onGeometryChange(for: CGFloat.self) {
$0.frame(in: .named(Self.layoutSpace)).minX
} action: {
recordPinnedTabStripWidth($0)
}
}
.navigationSplitViewStyle(.balanced)
}
+378 -24
View File
@@ -7,6 +7,20 @@ nonisolated struct AgentHostTarget: Identifiable, Hashable, Sendable, Codable {
var tags: [String]
var connectionState: String
var isTemporary: Bool
var groupID: HostGroup.ID?
var groupPath: String?
}
nonisolated struct AgentHostGroupTarget: Identifiable, Hashable, Sendable {
var id: HostGroup.ID
var name: String
var path: String
var children: [AgentHostGroupTarget]
}
nonisolated struct AgentHostListing: Hashable, Sendable {
var groups: [AgentHostGroupTarget]
var hosts: [AgentHostTarget]
}
/// Main-actor bridge over the Vault. The Agent receives only public host
@@ -39,15 +53,78 @@ final class AgentHostResolver: Sendable {
temporaryConfiguration = configuration
}
func targets() -> [AgentHostTarget] {
func listing() -> AgentHostListing {
let paths = groupPaths()
let saved = store.hosts.map { host in
AgentHostTarget(
id: host.id, displayName: host.name, address: store.loginAddress(for: host),
tags: host.tags, connectionState: Self.stateName(store.status(of: host.id)),
isTemporary: false
isTemporary: false, groupID: host.groupID,
groupPath: host.groupID.flatMap { paths[$0] }
)
}
return saved + temporaryTargets()
return AgentHostListing(
groups: groupTree(paths: paths),
hosts: saved + temporaryTargets().map { target in
var target = target
target.groupID = nil
target.groupPath = nil
return target
}
)
}
func targets() -> [AgentHostTarget] { listing().hosts }
/// Produces every valid path without trusting persisted parent links to be
/// acyclic. A broken parent simply ends the path; a cycle never recurses.
private func groupPaths() -> [HostGroup.ID: String] {
var result: [HostGroup.ID: String] = [:]
for group in store.groups {
var names: [String] = []
var current: HostGroup? = group
var visited: Set<HostGroup.ID> = []
while let node = current, visited.insert(node.id).inserted {
names.append(node.name)
current = node.parentID.flatMap(store.group)
}
result[group.id] = names.reversed().joined(separator: " / ")
}
return result
}
/// Roots include ordinary top-level groups and groups whose parent is
/// missing. Any cyclic component that has no root is appended afterwards,
/// exactly once, so corrupt metadata cannot make a group disappear.
private func groupTree(paths: [HostGroup.ID: String]) -> [AgentHostGroupTarget] {
let all = Dictionary(uniqueKeysWithValues: store.groups.map { ($0.id, $0) })
func ordered(_ groups: [HostGroup]) -> [HostGroup] {
groups.sorted {
if $0.sortIndex != $1.sortIndex { return $0.sortIndex < $1.sortIndex }
return $0.id.uuidString < $1.id.uuidString
}
}
var emitted: Set<HostGroup.ID> = []
func build(_ group: HostGroup, ancestors: Set<HostGroup.ID>) -> AgentHostGroupTarget {
emitted.insert(group.id)
let nextAncestors = ancestors.union([group.id])
let children = ordered(store.groups.filter { $0.parentID == group.id })
.filter { !nextAncestors.contains($0.id) }
.map { build($0, ancestors: nextAncestors) }
return AgentHostGroupTarget(
id: group.id, name: group.name, path: paths[group.id] ?? group.name,
children: children
)
}
let roots = ordered(store.groups.filter { group in
guard let parentID = group.parentID else { return true }
return all[parentID] == nil
})
var result = roots.map { build($0, ancestors: []) }
for group in ordered(store.groups) where !emitted.contains(group.id) {
result.append(build(group, ancestors: []))
}
return result
}
func configuration(for id: UUID) throws -> SSHConnectionConfiguration {
@@ -56,6 +133,179 @@ final class AgentHostResolver: Sendable {
throw AgentToolError.invalidHost
}
fileprivate struct InteractiveResolution: Sendable {
var configuration: SSHConnectionConfiguration
var submissions: [Host.ID: SSHAuthenticationSubmission]
}
/// Resolves every local authentication checkpoint in route order. The
/// interaction handler receives public metadata and returns secrets
/// directly to this main-actor bridge; none of it becomes tool content.
fileprivate func interactiveConfiguration(
for id: UUID,
invocationID: AgentToolInvocation.ID,
interaction: any AgentUserInteractionHandling,
submissions initial: [Host.ID: SSHAuthenticationSubmission],
forcedHostID initialForcedHostID: Host.ID? = nil,
failure initialFailure: String? = nil
) async throws -> InteractiveResolution {
guard let destination = store.host(id) else {
// Pane-backed targets already carry a complete configuration and
// cannot be saved back to the Vault from an Agent card.
if let configuration = try temporaryConfiguration(id) {
return InteractiveResolution(
configuration: configuration, submissions: initial
)
}
throw AgentToolError.invalidHost
}
var submissions = initial
var forcedHostID = initialForcedHostID
var failure = initialFailure
while let requirement = authenticationRequirement(
for: destination, submissions: submissions, forcedHostID: forcedHostID
) {
let node = requirement.host
let existing = submissions[node.id]
let identity = existing?.identityID.flatMap(store.identity)
?? store.identity(for: node)
let request = AgentSSHAuthenticationRequest(
invocationID: invocationID, hostID: node.id,
displayName: node.name, address: node.displayAddress,
mode: requirement.mode,
selectedIdentityID: existing?.identityID ?? identity?.id,
username: existing?.username ?? identity?.username ?? "",
selectedKeyID: existing?.keyID ?? identity?.keyID,
failureMessage: failure
)
switch await interaction.requestSSHAuthentication(request) {
case .cancelled:
throw AgentToolError.cancelled
case .submit(let answer):
if case .passphrase = requirement.mode,
var retained = submissions[node.id]
{
// A protected key may have been chosen beside a password
// in the preceding Identity step. The passphrase card only
// returns the newly-entered secret, so merge it into that
// staged submission instead of replacing (and silently
// dropping) the password and other choices.
guard let passphrase = answer.passphrase, !passphrase.isEmpty else {
throw TermindSSHError.keyPassphraseRequired
}
retained.passphrase = passphrase
submissions[node.id] = retained
} else {
submissions[node.id] = try submission(from: answer, for: node)
}
}
forcedHostID = nil
failure = nil
}
return InteractiveResolution(
configuration: try store.sshConnectionConfiguration(
for: destination, submissions: submissions
),
submissions: submissions
)
}
private struct AuthenticationRequirement {
var host: Host
var mode: AgentSSHAuthenticationRequest.Mode
}
private func authenticationRequirement(
for destination: Host,
submissions: [Host.ID: SSHAuthenticationSubmission],
forcedHostID: Host.ID?
) -> AuthenticationRequirement? {
let route = store.route(for: destination)
for node in route {
guard forcedHostID == nil || forcedHostID == node.id else { continue }
if let submission = submissions[node.id] {
if let keyID = submission.keyID,
store.key(keyID)?.requiresPassphraseEntry == true,
submission.passphrase?.isEmpty != false
{
return AuthenticationRequirement(host: node, mode: .passphrase(keyID: keyID))
}
if submission.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| (submission.keyID == nil && submission.password == nil)
{
return AuthenticationRequirement(host: node, mode: .identity)
}
if forcedHostID == node.id {
return AuthenticationRequirement(host: node, mode: .identity)
}
continue
}
guard let identity = store.identity(for: node) else {
return AuthenticationRequirement(host: node, mode: .identity)
}
let resolution = store.sshCredentials(for: identity)
if let keyID = resolution.requiredPassphraseKeyID {
return AuthenticationRequirement(host: node, mode: .passphrase(keyID: keyID))
}
if identity.username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| resolution.credentials.isEmpty
{
return AuthenticationRequirement(host: node, mode: .identity)
}
if forcedHostID == node.id {
return AuthenticationRequirement(host: node, mode: .identity)
}
}
return nil
}
private func submission(
from answer: AgentSSHAuthenticationAnswer, for host: Host
) throws -> SSHAuthenticationSubmission {
if let identityID = answer.identityID {
guard let identity = store.identity(identityID) else {
throw TermindSSHError.missingCredential
}
let password = try store.passwordCredential(for: identity)
if answer.save {
var updated = host
updated.identityID = identity.id
store.upsert(updated)
}
return SSHAuthenticationSubmission(
username: identity.username, password: password,
passphrase: answer.passphrase, keyID: identity.keyID,
identityID: identity.id
)
}
let username = answer.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
var identityID: Identity.ID?
if answer.save {
let name = answer.identityName?.trimmingCharacters(in: .whitespacesAndNewlines)
let identity = Identity(
name: name?.isEmpty == false ? name! : username,
username: username, keyID: answer.keyID
)
try store.upsert(
identity,
passwordUpdate: answer.password.map(IdentityPasswordUpdate.replace) ?? .remove
)
var updated = host
updated.identityID = identity.id
store.upsert(updated)
identityID = identity.id
}
return SSHAuthenticationSubmission(
username: username, password: answer.password,
passphrase: answer.passphrase, keyID: answer.keyID,
identityID: identityID
)
}
/// A stable description of *which machine, reached how, and as whom* — the
/// only question a pooled connection has to keep answering the same way.
///
@@ -101,6 +351,56 @@ final class AgentHostResolver: Sendable {
}
}
/// Keeps temporary credentials alive for exactly one command-opening journey,
/// including the pool's one safe reconnect. Once `ssh_exec` returns, this actor
/// and its cached configuration are released.
actor AgentSSHConfigurationSession {
private let resolver: AgentHostResolver
private let hostID: Host.ID
private let invocationID: AgentToolInvocation.ID
private let interaction: any AgentUserInteractionHandling
private var submissions: [Host.ID: SSHAuthenticationSubmission] = [:]
private var cached: SSHConnectionConfiguration?
private var forcedHostID: Host.ID?
private var failure: String?
init(
resolver: AgentHostResolver, hostID: Host.ID,
invocationID: AgentToolInvocation.ID,
interaction: any AgentUserInteractionHandling
) {
self.resolver = resolver
self.hostID = hostID
self.invocationID = invocationID
self.interaction = interaction
}
func configuration() async throws -> SSHConnectionConfiguration {
if let cached { return cached }
let resolution = try await resolver.interactiveConfiguration(
for: hostID, invocationID: invocationID, interaction: interaction,
submissions: submissions, forcedHostID: forcedHostID, failure: failure
)
submissions = resolution.submissions
cached = resolution.configuration
forcedHostID = nil
failure = nil
return resolution.configuration
}
func retry(after error: TermindSSHError) {
let target = error.routeHostID ?? hostID
if var retained = submissions[target] {
retained.password = nil
retained.passphrase = nil
submissions[target] = retained
}
cached = nil
forcedHostID = target
failure = error.localizedDescription
}
}
/// Keeps one authenticated SSH-exec session and one SFTP session per host alive
/// across tool calls, runs, and conversations.
///
@@ -189,30 +489,37 @@ actor AgentConnectionPool {
/// half of an `rm` is worse than a failed tool call.
func execute(
_ command: String, workingDirectory: String?, stdin: Data?,
timeout: TimeInterval, outputLimit: Int, hostID: UUID
timeout: TimeInterval, outputLimit: Int, hostID: UUID,
configuration override: ConfigurationResolving? = nil
) async throws -> SSHExecResult {
do {
return try await exec(hostID: hostID).execute(
return try await exec(hostID: hostID, configuration: override).execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit
)
} catch SSHExecSessionError.channelUnavailable {
await disconnect(hostID: hostID, kind: .exec)
return try await exec(hostID: hostID).execute(
return try await exec(hostID: hostID, configuration: override).execute(
command, workingDirectory: workingDirectory, stdin: stdin,
timeout: timeout, outputLimit: outputLimit
)
}
}
func exec(hostID: UUID) async throws -> any SSHExecSessionProtocol {
guard case .exec(let session) = try await resolve(hostID: hostID, kind: .exec)
func exec(
hostID: UUID, configuration override: ConfigurationResolving? = nil
) async throws -> any SSHExecSessionProtocol {
guard case .exec(let session) = try await resolve(
hostID: hostID, kind: .exec, configuration: override
)
else { throw AgentToolError.invalidHost }
return session
}
func sftp(hostID: UUID) async throws -> any SFTPClientProtocol {
guard case .sftp(let client) = try await resolve(hostID: hostID, kind: .sftp)
guard case .sftp(let client) = try await resolve(
hostID: hostID, kind: .sftp, configuration: nil
)
else { throw AgentToolError.invalidHost }
return client
}
@@ -262,7 +569,9 @@ actor AgentConnectionPool {
var openConnectionCount: Int { entries.count }
private func resolve(hostID: UUID, kind: Kind) async throws -> Client {
private func resolve(
hostID: UUID, kind: Kind, configuration override: ConfigurationResolving?
) async throws -> Client {
let fingerprint: String
do {
fingerprint = try await routeFingerprint(hostID)
@@ -279,26 +588,31 @@ actor AgentConnectionPool {
// libssh2 session in `deinit`, so dropping the reference alone
// would be safe; this just makes it prompt.
Task { await entry.client.disconnect() }
return try await open(key: key, fingerprint: fingerprint)
return try await open(
key: key, fingerprint: fingerprint, configuration: override
)
}
entries[key] = Entry(
client: entry.client, fingerprint: fingerprint, lastUsedAt: .now
)
return entry.client
}
return try await open(key: key, fingerprint: fingerprint)
return try await open(key: key, fingerprint: fingerprint, configuration: override)
}
private func open(key: Key, fingerprint: String) async throws -> Client {
private func open(
key: Key, fingerprint: String, configuration override: ConfigurationResolving?
) async throws -> Client {
// Without this join, two tool calls that both miss the cache would each
// build a connection and one of them would never be pooled — or closed.
if let inFlight = pending[key], inFlight.fingerprint == fingerprint {
let client = try await inFlight.task.value
commit(client, key: key, fingerprint: fingerprint)
let committedFingerprint = try await routeFingerprint(key.hostID)
commit(client, key: key, fingerprint: committedFingerprint)
return client
}
let task = Task { [configuration, openExec, openSFTP] () async throws -> Client in
let resolved = try await configuration(key.hostID)
let resolved = try await (override ?? configuration)(key.hostID)
switch key.kind {
case .exec: return .exec(try await openExec(resolved))
case .sftp: return .sftp(try await openSFTP(resolved))
@@ -310,7 +624,11 @@ actor AgentConnectionPool {
// that started the task: whoever started it may have been cancelled
// while a second caller was still waiting on the same connection.
let client = try await task.value
commit(client, key: key, fingerprint: fingerprint)
// Saving/binding an Identity while the connection prompt is open
// legitimately changes the route fingerprint. Bind the authenticated
// session to that post-save route so the next command can reuse it.
let committedFingerprint = try await routeFingerprint(key.hostID)
commit(client, key: key, fingerprint: committedFingerprint)
return client
}
@@ -350,7 +668,7 @@ nonisolated enum AgentBuiltInTools: AgentToolSchemaBuilding {
search: WebSearchClient? = nil
) -> [AnyAgentTool] {
[
listHosts(hosts), sshExec(connections), terminalSuggest(),
listHosts(hosts), sshExec(hosts, connections), terminalSuggest(),
sftpList(connections), sftpStat(connections), sftpRead(connections),
sftpWrite(connections), sftpMkdir(connections), sftpMove(connections),
sftpChmod(connections), sftpDelete(connections), fetch(fetcher),
@@ -364,19 +682,34 @@ nonisolated enum AgentBuiltInTools: AgentToolSchemaBuilding {
properties: [:], required: [], target: .host, safety: .locallyReadOnly,
concurrency: .parallel
)) { invocation, _ in
let rows = await hosts.targets().map { target in
let listing = await hosts.listing()
let rows = listing.hosts.map { target in
AgentJSONValue.object([
"id": .string(target.id.uuidString), "display_name": .string(target.displayName),
"address": .string(target.address), "tags": .array(target.tags.map(AgentJSONValue.string)),
"connection_state": .string(target.connectionState),
"temporary": .bool(target.isTemporary),
"group_id": target.groupID.map { .string($0.uuidString) } ?? .null,
"group_path": target.groupPath.map(AgentJSONValue.string) ?? .null,
])
}
return result(invocation, .array(rows))
func groupJSON(_ group: AgentHostGroupTarget) -> AgentJSONValue {
.object([
"id": .string(group.id.uuidString), "name": .string(group.name),
"path": .string(group.path),
"children": .array(group.children.map(groupJSON)),
])
}
return result(invocation, .object([
"groups": .array(listing.groups.map(groupJSON)),
"hosts": .array(rows),
]))
}
}
private static func sshExec(_ pool: AgentConnectionPool) -> AnyAgentTool {
private static func sshExec(
_ hosts: AgentHostResolver, _ pool: AgentConnectionPool
) -> AnyAgentTool {
let tool = descriptor(
"ssh_exec", "Execute one command over a dedicated non-PTY SSH exec channel.",
properties: [
@@ -436,11 +769,32 @@ nonisolated enum AgentBuiltInTools: AgentToolSchemaBuilding {
)
input = Data(secret.utf8)
}
let output = try await pool.execute(
command, workingDirectory: arguments.optionalString("cwd"), stdin: input,
timeout: TimeInterval(arguments.optionalInt("timeout_seconds") ?? 60),
outputLimit: 256 * 1_024, hostID: hostID
let authentication = AgentSSHConfigurationSession(
resolver: hosts, hostID: hostID, invocationID: invocation.id,
interaction: context.userInteraction
)
let interactiveConfiguration: AgentConnectionPool.ConfigurationResolving = { _ in
try await authentication.configuration()
}
let output: SSHExecResult
while true {
do {
output = try await pool.execute(
command, workingDirectory: arguments.optionalString("cwd"), stdin: input,
timeout: TimeInterval(arguments.optionalInt("timeout_seconds") ?? 60),
outputLimit: 256 * 1_024, hostID: hostID,
configuration: interactiveConfiguration
)
break
} catch let error as TermindSSHError where error.shouldRequestAuthentication {
// Authentication failed before an exec channel — and
// therefore before the command could run. It is safe to
// collect another credential and reopen; command errors
// never enter this path.
await pool.disconnect(hostID: hostID, kind: .exec)
await authentication.retry(after: error)
}
}
return result(invocation, .object([
"stdout": .string(output.stdout), "stderr": .string(output.stderr),
"exit_code": .number(Double(output.exitCode)),
@@ -241,6 +241,7 @@ nonisolated struct AgentUserQuestion: Identifiable, Hashable, Sendable {
/// answering twice in a row; this makes it one form instead.
nonisolated struct AgentUserInputRequest: Identifiable, Hashable, Sendable {
var id: UUID = UUID()
var createdAt: Date = .now
var questions: [AgentUserQuestion]
var purpose: String = ""
var hostID: UUID?
@@ -281,10 +282,55 @@ nonisolated enum AgentUserInputResponse: Hashable, Sendable {
case cancelled
}
/// A local SSH checkpoint raised by `ssh_exec`. Unlike `request_user_input`,
/// this request is never model-authored and its answer is never serialized into
/// a tool result. Only public Vault metadata crosses into the view.
nonisolated struct AgentSSHAuthenticationRequest: Identifiable, Hashable, Sendable {
nonisolated enum Mode: Hashable, Sendable {
case identity
case passphrase(keyID: SSHKeyRef.ID)
}
var id: UUID = UUID()
var createdAt: Date = .now
var invocationID: AgentToolInvocation.ID
var hostID: Host.ID
var displayName: String
var address: String
var mode: Mode
var selectedIdentityID: Identity.ID?
var username: String
var selectedKeyID: SSHKeyRef.ID?
var failureMessage: String?
}
nonisolated struct AgentSSHAuthenticationAnswer: Hashable, Sendable {
/// Existing identity when selected; nil means the inline Custom form.
var identityID: Identity.ID?
var identityName: String?
var username: String
var password: String?
var passphrase: String?
var keyID: SSHKeyRef.ID?
/// Existing identities are bound; inline values create an Identity.
var save: Bool
}
nonisolated enum AgentSSHAuthenticationResponse: Hashable, Sendable {
case submit(AgentSSHAuthenticationAnswer)
case cancelled
}
nonisolated protocol AgentUserInteractionHandling: Sendable {
func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse
}
nonisolated struct UnavailableAgentUserInteraction: AgentUserInteractionHandling, Sendable {
func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse { .cancelled }
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse { .cancelled }
}
@@ -234,4 +234,13 @@ private nonisolated struct EventingAgentUserInteraction: AgentUserInteractionHan
channel.emit(.userInputRequested(request))
return await base.request(request)
}
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse {
// The request is locally derived rather than model-authored. The
// conversation queues it directly; deliberately no event or durable
// record is produced on the path that will return credentials.
await base.requestSSHAuthentication(request)
}
}
+1 -1
View File
@@ -34,7 +34,7 @@ nonisolated enum SSHRouteEvent: Hashable, Sendable {
typealias SSHRouteProgressHandler = @Sendable (SSHRouteEvent) -> Void
nonisolated struct SSHAuthenticationSubmission: Sendable {
nonisolated struct SSHAuthenticationSubmission: Hashable, Sendable {
var username: String
var password: String?
var passphrase: String?
@@ -24,9 +24,25 @@ nonisolated enum KeychainSecretError: LocalizedError {
}
}
/// The Vault's typed secret boundary. Keeping the store behind this small
/// interface lets connection tests prove password/passphrase lifetime rules
/// without depending on a signed Keychain-enabled test host.
nonisolated protocol HostSecretStoring: Sendable {
func string(for account: String, storage: PasswordStorage) throws -> String?
func setString(_ value: String?, for account: String, storage: PasswordStorage) throws
func keyCredential(
for account: String, storage: KeyStorage
) throws -> StoredSSHKeyCredential?
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws
func removePassword(_ account: String, storage: PasswordStorage) throws
func removeKeyCredential(_ account: String, storage: KeyStorage) throws
}
/// Typed storage in the macOS Data Protection Keychain. Independent services
/// prevent a damaged reference from reinterpreting one credential as another.
nonisolated struct KeychainSecretStore: Sendable {
nonisolated struct KeychainSecretStore: HostSecretStoring, Sendable {
private static let passwordService = "com.akinokaede.termind.passwords.v1"
private static let softwareKeyService = "com.akinokaede.termind.ssh-software-keys.v1"
private static let enclaveService = "com.akinokaede.termind.secure-enclave-handles.v1"
+50 -28
View File
@@ -21,7 +21,7 @@ final class HostStore {
/// The latest local persistence failure. Secret text is never included.
private(set) var storageError: String?
private var persistence: HostPersistence?
private let keychain: KeychainSecretStore
private let keychain: any HostSecretStoring
/// Runtime resources live in AppModel, so deletion announces before model
/// references are removed and the controller can stop listeners cleanly.
var willDeleteHost: ((Host.ID, [PortForwardingRule.ID]) -> Void)?
@@ -58,7 +58,7 @@ final class HostStore {
portForwardings: [PortForwardingRule] = [],
sftpTransfers: [SFTPTransfer] = [],
knownHosts: [KnownHostEntry] = [],
keychain: KeychainSecretStore = KeychainSecretStore()
keychain: any HostSecretStoring = KeychainSecretStore()
) {
self.hosts = hosts
self.groups = groups
@@ -132,7 +132,10 @@ final class HostStore {
}
}
convenience init(modelContext: ModelContext, keychain: KeychainSecretStore = KeychainSecretStore()) {
convenience init(
modelContext: ModelContext,
keychain: any HostSecretStoring = KeychainSecretStore()
) {
self.init(keychain: keychain)
let persistence = HostPersistence(context: modelContext)
self.persistence = persistence
@@ -369,23 +372,29 @@ final class HostStore {
submissions: [Host.ID: SSHAuthenticationSubmission] = [:],
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:]
) throws -> SSHConnectionConfiguration {
guard let identity = identity(for: host) else {
throw TermindSSHError.missingCredential
let username: String
let credentials: [SSHCredentials]
if let submission = submissions[host.id] {
username = submission.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
credentials = try sshCredentials(for: submission)
} else {
guard let identity = identity(for: host) else {
throw TermindSSHError.missingCredential
}
username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
let resolution = sshCredentials(for: identity)
guard resolution.requiredPassphraseKeyID == nil else {
throw TermindSSHError.keyPassphraseRequired
}
credentials = resolution.credentials
}
let username = identity.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
guard !credentials.isEmpty else { throw TermindSSHError.missingCredential }
let expectedHostKeys = try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys
)
let credentialResolution = sshCredentials(for: identity)
guard credentialResolution.requiredPassphraseKeyID == nil else {
throw TermindSSHError.keyPassphraseRequired
}
guard !credentialResolution.credentials.isEmpty else {
throw TermindSSHError.missingCredential
}
guard let route = validatedRoute(for: host) else {
throw TermindSSHError.route(String(localized: "The Jump Host route is invalid or exceeds eight hosts."))
}
@@ -421,7 +430,7 @@ final class HostStore {
return SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentialResolution.credentials,
credentials: credentials,
expectedHostKeys: expectedHostKeys,
proxy: try transportProxy(for: host), jumps: jumps
)
@@ -503,18 +512,8 @@ final class HostStore {
let expectedHostKeys = try expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys
)
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID),
let stored = try keyCredential(for: key)
{
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: submission.passphrase
)
)
}
if let password = submission.password { credentials.append(.password(password)) }
let credentials = try sshCredentials(for: submission)
guard !credentials.isEmpty else { throw TermindSSHError.missingCredential }
return SSHRouteHopConfiguration(
id: host.id, name: host.name, host: host.host, port: host.port,
username: username, credentials: credentials,
@@ -523,6 +522,29 @@ final class HostStore {
)
}
private func sshCredentials(
for submission: SSHAuthenticationSubmission
) throws -> [SSHCredentials] {
var credentials: [SSHCredentials] = []
if let keyID = submission.keyID, let key = key(keyID) {
if key.requiresPassphraseEntry,
submission.passphrase?.isEmpty != false
{
throw TermindSSHError.keyPassphraseRequired
}
if let stored = try keyCredential(for: key) {
credentials.append(
stored.sshCredential(
publicKey: key.publicKey, certificate: key.certificate,
passphraseOverride: submission.passphrase
)
)
}
}
if let password = submission.password { credentials.append(.password(password)) }
return credentials
}
func transportProxy(for host: Host) throws -> SSHTransportProxy? {
guard let proxy = host.proxy else { return nil }
guard proxy.isValid else {
+50 -3
View File
@@ -82,7 +82,7 @@ nonisolated struct ChatMessage: Identifiable, Hashable, Sendable {
nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable {
nonisolated enum State: String, Hashable, Sendable {
case proposed, waitingForApproval, reviewing, running, completed, denied, failed
case proposed, waitingForApproval, waitingForUser, reviewing, running, completed, denied, failed
/// The transcript used to show `rawValue.capitalized`, which put the
/// case name itself on screen — `WaitingForApproval`. The raw value is
@@ -91,6 +91,7 @@ nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable {
switch self {
case .proposed: String(localized: "Proposed")
case .waitingForApproval: String(localized: "Waiting for approval")
case .waitingForUser: String(localized: "Waiting for authentication")
case .reviewing: String(localized: "Reviewing")
case .running: String(localized: "Running")
case .completed: String(localized: "Completed")
@@ -106,7 +107,7 @@ nonisolated struct AgentToolCard: Identifiable, Hashable, Sendable {
/// once it has worked.
var revealsDetailByDefault: Bool {
switch self {
case .waitingForApproval, .reviewing, .running, .denied, .failed: true
case .waitingForApproval, .waitingForUser, .reviewing, .running, .denied, .failed: true
case .proposed, .completed: false
}
}
@@ -228,6 +229,7 @@ final class ConversationModel: Identifiable {
/// someone to authorize three things while reading one.
private(set) var pendingApprovals: [AgentApprovalRequest] = []
private(set) var pendingUserInputs: [AgentUserInputRequest] = []
private(set) var pendingSSHAuthentications: [AgentSSHAuthenticationRequest] = []
/// What the user typed at a busy agent, oldest first, none of it delivered
/// yet. See `submitSteeringDraft`.
private(set) var pendingSteering: [PendingSteeringMessage] = []
@@ -237,6 +239,23 @@ final class ConversationModel: Identifiable {
var pendingApproval: AgentApprovalRequest? { pendingApprovals.first }
var pendingUserInput: AgentUserInputRequest? { pendingUserInputs.first }
var pendingSSHAuthentication: AgentSSHAuthenticationRequest? {
pendingSSHAuthentications.first
}
enum PendingUserInteraction {
case questions(AgentUserInputRequest)
case sshAuthentication(AgentSSHAuthenticationRequest)
}
var pendingUserInteraction: PendingUserInteraction? {
switch (pendingUserInput, pendingSSHAuthentication) {
case (let input?, let authentication?):
return input.createdAt <= authentication.createdAt
? .questions(input) : .sshAuthentication(authentication)
case (let input?, nil): return .questions(input)
case (nil, let authentication?): return .sshAuthentication(authentication)
case (nil, nil): return nil
}
}
/// What has been filled in so far, keyed by question. One request can now
/// carry several questions, so the drafts are per question rather than one
/// pair of fields shared by whichever one happened to be on screen.
@@ -257,6 +276,8 @@ final class ConversationModel: Identifiable {
private var eventTask: Task<Void, Never>?
private var approvalWaiters: [UUID: CheckedContinuation<AgentApprovalDecision, Never>] = [:]
private var inputWaiters: [UUID: CheckedContinuation<AgentUserInputResponse, Never>] = [:]
private var sshAuthenticationWaiters:
[UUID: CheckedContinuation<AgentSSHAuthenticationResponse, Never>] = [:]
/// Per-conversation reasoning state, normalized against the resolved model
/// by the composer before a request is made.
@@ -521,6 +542,25 @@ final class ConversationModel: Identifiable {
return await withCheckedContinuation { inputWaiters[request.id] = $0 }
}
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse {
pendingSSHAuthentications.append(request)
setToolState(request.invocationID, .waitingForUser)
refreshRunState()
return await withCheckedContinuation {
sshAuthenticationWaiters[request.id] = $0
}
}
func respondToSSHAuthentication(_ response: AgentSSHAuthenticationResponse) {
guard !pendingSSHAuthentications.isEmpty else { return }
let request = pendingSSHAuthentications.removeFirst()
setToolState(request.invocationID, .running)
refreshRunState()
sshAuthenticationWaiters.removeValue(forKey: request.id)?.resume(returning: response)
}
/// Puts a question on screen. The queue path uses it, and so does any
/// caller that wants the card without a run behind it.
func present(_ request: AgentUserInputRequest) {
@@ -654,7 +694,7 @@ final class ConversationModel: Identifiable {
private func refreshRunState() {
if runtimeState.isTerminal || runtimeState == .idle {
runState = runtimeState
} else if !pendingUserInputs.isEmpty {
} else if !pendingUserInputs.isEmpty || !pendingSSHAuthentications.isEmpty {
runState = .waitingForUser
} else if !pendingApprovals.isEmpty {
runState = .waitingForApproval
@@ -828,6 +868,13 @@ final class WeakAgentUserInteraction {
guard let conversation else { return .cancelled }
return await conversation.request(request)
}
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse {
guard let conversation else { return .cancelled }
return await conversation.requestSSHAuthentication(request)
}
}
extension WeakAgentUserInteraction: AgentUserInteractionHandling {}
@@ -22,9 +22,16 @@ struct ConversationView: View {
Divider()
AgentApprovalCard(request: approval, conversation: conversation)
}
if let input = conversation.pendingUserInput {
if let interaction = conversation.pendingUserInteraction {
Divider()
AgentUserInputCard(request: input, conversation: conversation)
switch interaction {
case .questions(let input):
AgentUserInputCard(request: input, conversation: conversation)
case .sshAuthentication(let authentication):
AgentSSHAuthenticationCard(
request: authentication, conversation: conversation
)
}
}
if !conversation.pendingSteering.isEmpty {
Divider()
@@ -195,6 +202,7 @@ private struct AgentToolRowView: View {
private var symbol: String {
switch card.state {
case .reviewing: "checkmark.shield"
case .waitingForUser: "key.fill"
case .running: "gearshape.2"
case .completed: "checkmark.circle.fill"
case .denied, .failed: "xmark.octagon"
@@ -205,7 +213,7 @@ private struct AgentToolRowView: View {
private var tint: Color {
switch card.state {
case .denied, .failed: .red
case .reviewing, .waitingForApproval: .orange
case .reviewing, .waitingForApproval, .waitingForUser: .orange
case .completed: .green
default: .secondary
}
@@ -266,6 +274,74 @@ private struct QueueDepthLabel: View {
}
}
/// SSH authentication presented in the Agent surface. Agent owns placement and
/// queue state; the actual credential form is shared with the Journey.
private struct AgentSSHAuthenticationCard: View {
let request: AgentSSHAuthenticationRequest
let conversation: ConversationModel
private var requiresPassphrase: Bool {
if case .passphrase = request.mode { return true }
return false
}
private var prompt: SSHAuthenticationPrompt {
SSHAuthenticationPrompt(
host: Host(
id: request.hostID, name: request.displayName,
host: request.address
),
selectedIdentityID: request.selectedIdentityID,
username: request.username,
selectedKeyID: request.selectedKeyID,
failureMessage: request.failureMessage,
requiresPassphrase: requiresPassphrase,
mode: request.failureMessage == nil ? .localIdentity : .serverChallenge
)
}
var body: some View {
VStack(spacing: 0) {
if conversation.pendingSSHAuthentications.count > 1 {
HStack {
Spacer()
QueueDepthLabel(
remaining: conversation.pendingSSHAuthentications.count
)
}
.padding(.bottom, Metrics.Space.s)
}
SSHAuthenticationForm(
prompt: prompt,
submit: submit,
cancel: {
conversation.respondToSSHAuthentication(.cancelled)
}
)
// A retry or the next route node must start with fresh SecureFields.
.id(request.id)
}
.padding(Metrics.Space.l)
}
private func submit(
identityID: Identity.ID?, username: String, password: String?,
passphrase: String?, keyID: SSHKeyRef.ID?, save: Bool
) {
conversation.respondToSSHAuthentication(.submit(
AgentSSHAuthenticationAnswer(
identityID: identityID,
username: username,
password: password,
passphrase: passphrase,
keyID: keyID,
save: requiresPassphrase ? false : save
)
))
}
}
/// The agent asking the user to decide one or more things.
///
/// A form rather than a run of cards. A turn's questions are usually one group
@@ -356,7 +356,8 @@ final class SSHSessionModel: Identifiable {
}
return AgentHostTarget(
id: host.id, displayName: host.name, address: host.displayAddress,
tags: host.tags, connectionState: state, isTemporary: true
tags: host.tags, connectionState: state, isTemporary: true,
groupID: nil, groupPath: nil
)
}
@@ -150,7 +150,7 @@ struct SSHConnectionJourneyView: View {
if journey.showsLogs {
logs
} else if let prompt = authenticationPrompt {
JourneyAuthenticationCard(
SSHAuthenticationForm(
prompt: prompt,
submit: submitAuthentication,
cancel: cancel
@@ -526,7 +526,10 @@ private struct JourneyActivityRing: View {
}
}
private struct JourneyAuthenticationCard: View {
/// Shared SSH credential form used by both the connection Journey and Agent.
/// The containing surface owns placement and queue state; this view owns the
/// established Custom/Identity choices, button language, and submission flow.
struct SSHAuthenticationForm: View {
let prompt: SSHAuthenticationPrompt
let submit: (Identity.ID?, String, String?, String?, SSHKeyRef.ID?, Bool) -> Void
let cancel: () -> Void
@@ -0,0 +1,228 @@
import Foundation
import Testing
@testable import Termind
@Suite(.serialized)
@MainActor
struct AgentHostAuthenticationTests {
@Test
func listingIncludesNestedAndEmptyGroupsAndHostPaths() throws {
let root = HostGroup(name: "Production", sortIndex: 0)
let child = HostGroup(name: "Web", parentID: root.id, sortIndex: 0)
let empty = HostGroup(name: "Empty", sortIndex: 1)
let grouped = Host(name: "Web 01", host: "web.example", groupID: child.id)
let ungrouped = Host(name: "Loose", host: "loose.example")
let temporary = AgentHostTarget(
id: UUID(), displayName: "Pane", address: "pane.example", tags: [],
connectionState: "connected", isTemporary: true,
groupID: UUID(), groupPath: "must be cleared"
)
let store = HostStore(
hosts: [grouped, ungrouped], groups: [empty, child, root]
)
let resolver = AgentHostResolver(store: store, temporaryTargets: { [temporary] })
let listing = resolver.listing()
#expect(listing.groups.map(\.name) == ["Production", "Empty"])
#expect(listing.groups[0].children.map(\.name) == ["Web"])
#expect(listing.groups[1].children.isEmpty)
#expect(listing.hosts.first { $0.id == grouped.id }?.groupID == child.id)
#expect(
listing.hosts.first { $0.id == grouped.id }?.groupPath
== "Production / Web"
)
#expect(listing.hosts.first { $0.id == ungrouped.id }?.groupPath == nil)
#expect(listing.hosts.first { $0.id == temporary.id }?.groupID == nil)
#expect(listing.hosts.first { $0.id == temporary.id }?.groupPath == nil)
}
@Test
func inlineIdentityCanBeUsedOnceWithoutChangingTheVault() async throws {
let host = Host(name: "Server", host: "server.example")
let store = HostStore(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")]
)
let interaction = AuthenticationResponder { request in
#expect(request.hostID == host.id)
return .submit(AgentSSHAuthenticationAnswer(
identityName: "Deploy", username: "deploy", password: "secret",
save: false
))
}
let session = AgentSSHConfigurationSession(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction
)
let configuration = try await session.configuration()
#expect(configuration.username == "deploy")
#expect(configuration.credentials.count == 1)
#expect(store.host(host.id)?.identityID == nil)
#expect(store.identities.isEmpty)
}
@Test
func inlineIdentityCanBeCreatedAndBound() async throws {
let host = Host(name: "Server", host: "server.example")
let secrets = MemoryHostSecretStore()
let store = HostStore(
hosts: [host],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
keychain: secrets
)
let interaction = AuthenticationResponder { _ in
.submit(AgentSSHAuthenticationAnswer(
identityName: "Production Deploy", username: "deploy",
password: "secret", save: true
))
}
let session = AgentSSHConfigurationSession(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction
)
let configuration = try await session.configuration()
let saved = try #require(store.identities.first)
defer { _ = store.delete(identityID: saved.id) }
#expect(saved.name == "Production Deploy")
#expect(saved.username == "deploy")
#expect(saved.passwordStorage == .iCloudKeychain)
#expect(store.host(host.id)?.identityID == saved.id)
#expect(try store.passwordCredential(for: saved) == "secret")
#expect(configuration.username == "deploy")
}
@Test
func passwordAndProtectedKeyPassphraseAreBothRetainedForTheConnection() async throws {
let keychain = MemoryHostSecretStore()
let reference = "termind.key.\(UUID().uuidString)"
let key = SSHKeyRef(
label: "Protected", publicKey: "ssh-ed25519 test",
fingerprint: "SHA256:key", credentialRef: reference,
storage: .thisDevice, hasPassphrase: true
)
try keychain.setKeyCredential(
.software(SSHPrivateKeySecret(privateKey: "encrypted", passphrase: nil)),
for: reference, storage: .thisDevice
)
let host = Host(name: "Server", host: "server.example")
let store = HostStore(
hosts: [host], keys: [key],
knownHosts: [KnownHostEntry(hostname: host.host, fingerprint: "SHA256:test")],
keychain: keychain
)
let interaction = AuthenticationResponder { request in
switch request.mode {
case .identity:
return .submit(AgentSSHAuthenticationAnswer(
username: "deploy", password: "fallback-password",
keyID: key.id, save: false
))
case .passphrase(let keyID):
#expect(keyID == key.id)
return .submit(AgentSSHAuthenticationAnswer(
username: request.username, passphrase: "one-use",
keyID: key.id, save: false
))
}
}
let session = AgentSSHConfigurationSession(
resolver: AgentHostResolver(store: store), hostID: host.id,
invocationID: UUID(), interaction: interaction
)
let configuration = try await session.configuration()
guard case .privateKey(_, _, let passphrase) = configuration.credentials.first else {
Issue.record("Expected a private-key credential")
return
}
#expect(passphrase == "one-use")
guard configuration.credentials.count == 2,
case .password(let password) = configuration.credentials[1]
else {
Issue.record("Expected the password from the Identity step to remain available")
return
}
#expect(password == "fallback-password")
guard case .software(let stored)? = try keychain.keyCredential(
for: reference, storage: .thisDevice
) else {
Issue.record("Expected the stored software key")
return
}
#expect(stored.passphrase == nil)
#expect(store.key(key.id)?.storesPassphrase == false)
}
}
private final class MemoryHostSecretStore: HostSecretStoring, @unchecked Sendable {
private struct PasswordKey: Hashable {
var account: String
var storage: PasswordStorage
}
private struct KeyKey: Hashable {
var account: String
var storage: KeyStorage
}
private var passwords: [PasswordKey: String] = [:]
private var keys: [KeyKey: StoredSSHKeyCredential] = [:]
func string(for account: String, storage: PasswordStorage) throws -> String? {
passwords[PasswordKey(account: account, storage: storage)]
}
func setString(
_ value: String?, for account: String, storage: PasswordStorage
) throws {
passwords[PasswordKey(account: account, storage: storage)] = value
}
func keyCredential(
for account: String, storage: KeyStorage
) throws -> StoredSSHKeyCredential? {
keys[KeyKey(account: account, storage: storage)]
}
func setKeyCredential(
_ value: StoredSSHKeyCredential?, for account: String, storage: KeyStorage
) throws {
keys[KeyKey(account: account, storage: storage)] = value
}
func removePassword(_ account: String, storage: PasswordStorage) throws {
passwords[PasswordKey(account: account, storage: storage)] = nil
}
func removeKeyCredential(_ account: String, storage: KeyStorage) throws {
keys[KeyKey(account: account, storage: storage)] = nil
}
}
private final class AuthenticationResponder: AgentUserInteractionHandling, @unchecked Sendable {
private let answer:
@MainActor (AgentSSHAuthenticationRequest) -> AgentSSHAuthenticationResponse
init(
answer: @escaping @MainActor
(AgentSSHAuthenticationRequest) -> AgentSSHAuthenticationResponse
) {
self.answer = answer
}
func request(_ request: AgentUserInputRequest) async -> AgentUserInputResponse {
.cancelled
}
func requestSSHAuthentication(
_ request: AgentSSHAuthenticationRequest
) async -> AgentSSHAuthenticationResponse {
await answer(request)
}
}