diff --git a/Termind/App/RootView.swift b/Termind/App/RootView.swift index c63fd73d..efa3a8fc 100644 --- a/Termind/App/RootView.swift +++ b/Termind/App/RootView.swift @@ -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) } diff --git a/Termind/Application/Agent/AgentBuiltInTools.swift b/Termind/Application/Agent/AgentBuiltInTools.swift index 0bb2f977..3bea5315 100644 --- a/Termind/Application/Agent/AgentBuiltInTools.swift +++ b/Termind/Application/Agent/AgentBuiltInTools.swift @@ -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 = [] + 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 = [] + func build(_ group: HostGroup, ancestors: Set) -> 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)), diff --git a/Termind/Application/Agent/AgentSecurity.swift b/Termind/Application/Agent/AgentSecurity.swift index d557ec21..d4a6b53d 100644 --- a/Termind/Application/Agent/AgentSecurity.swift +++ b/Termind/Application/Agent/AgentSecurity.swift @@ -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 } } diff --git a/Termind/Application/Agent/AgentToolExecutor.swift b/Termind/Application/Agent/AgentToolExecutor.swift index 98cb736a..f08b7b01 100644 --- a/Termind/Application/Agent/AgentToolExecutor.swift +++ b/Termind/Application/Agent/AgentToolExecutor.swift @@ -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) + } } diff --git a/Termind/Core/SSH/SSHConnectionJourney.swift b/Termind/Core/SSH/SSHConnectionJourney.swift index 8d1fe1bc..b80e0f4c 100644 --- a/Termind/Core/SSH/SSHConnectionJourney.swift +++ b/Termind/Core/SSH/SSHConnectionJourney.swift @@ -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? diff --git a/Termind/Core/Security/KeychainSecretStore.swift b/Termind/Core/Security/KeychainSecretStore.swift index 1defffa1..2ff0f1d2 100644 --- a/Termind/Core/Security/KeychainSecretStore.swift +++ b/Termind/Core/Security/KeychainSecretStore.swift @@ -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" diff --git a/Termind/Core/Stores/HostStore.swift b/Termind/Core/Stores/HostStore.swift index 72481295..bccce824 100644 --- a/Termind/Core/Stores/HostStore.swift +++ b/Termind/Core/Stores/HostStore.swift @@ -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 { diff --git a/Termind/Features/AI/ConversationModel.swift b/Termind/Features/AI/ConversationModel.swift index 49134367..0ce480ec 100644 --- a/Termind/Features/AI/ConversationModel.swift +++ b/Termind/Features/AI/ConversationModel.swift @@ -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? private var approvalWaiters: [UUID: CheckedContinuation] = [:] private var inputWaiters: [UUID: CheckedContinuation] = [:] + private var sshAuthenticationWaiters: + [UUID: CheckedContinuation] = [:] /// 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 {} diff --git a/Termind/Features/AI/ConversationTranscriptView.swift b/Termind/Features/AI/ConversationTranscriptView.swift index 71c42baf..156858e8 100644 --- a/Termind/Features/AI/ConversationTranscriptView.swift +++ b/Termind/Features/AI/ConversationTranscriptView.swift @@ -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 diff --git a/Termind/Features/Terminal/SSHSessionModel.swift b/Termind/Features/Terminal/SSHSessionModel.swift index 98f22ea2..a4b5be09 100644 --- a/Termind/Features/Terminal/SSHSessionModel.swift +++ b/Termind/Features/Terminal/SSHSessionModel.swift @@ -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 ) } diff --git a/Termind/UI/Components/SSHConnectionJourneyView.swift b/Termind/UI/Components/SSHConnectionJourneyView.swift index 23c1933e..add50d00 100644 --- a/Termind/UI/Components/SSHConnectionJourneyView.swift +++ b/Termind/UI/Components/SSHConnectionJourneyView.swift @@ -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 diff --git a/TermindTests/AgentHostAuthenticationTests.swift b/TermindTests/AgentHostAuthenticationTests.swift new file mode 100644 index 00000000..77fd9bf8 --- /dev/null +++ b/TermindTests/AgentHostAuthenticationTests.swift @@ -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) + } +}