import AgentKit import Foundation import TermindDomain import struct TermindDomain.Host /// A local SSH checkpoint raised by an Agent Exec or SFTP connection. 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 public struct AgentSSHAuthenticationRequest: Identifiable, Hashable, Sendable { public nonisolated enum Mode: Hashable, Sendable { case identity case proxy(ownerHostID: Host.ID) case keyboardInteractive(SSHKeyboardInteractiveChallenge) case passphrase(keyID: SSHKeyRef.ID) } public var id: UUID = UUID() public var createdAt: Date = .now public var invocationID: AgentToolInvocation.ID public var hostID: Host.ID public var displayName: String public var address: String public var mode: Mode public var selectedIdentityID: Identity.ID? public var username: String public var selectedKeyID: SSHKeyRef.ID? public var failureMessage: String? public var requiresPassphrase: Bool { if case .passphrase = mode { true } else { false } } public var keyboardInteractive: SSHKeyboardInteractiveChallenge? { if case .keyboardInteractive(let challenge) = mode { challenge } else { nil } } public var journeyPrompt: SSHAuthenticationPrompt { let target: SSHAuthenticationPrompt.Target switch mode { case .proxy(let ownerHostID): target = .proxy(ownerHostID: ownerHostID) default: target = .host } return SSHAuthenticationPrompt( host: Host(id: hostID, name: displayName, host: address), selectedIdentityID: selectedIdentityID, username: username, selectedKeyID: selectedKeyID, failureMessage: failureMessage, requiresPassphrase: requiresPassphrase, mode: failureMessage == nil ? .localIdentity : .serverChallenge, keyboardInteractive: keyboardInteractive, target: target ) } public init( id: UUID = UUID(), createdAt: Date = .now, invocationID: AgentToolInvocation.ID, hostID: Host.ID, displayName: String, address: String, mode: Mode, selectedIdentityID: Identity.ID? = nil, username: String, selectedKeyID: SSHKeyRef.ID? = nil, failureMessage: String? = nil ) { self.id = id self.createdAt = createdAt self.invocationID = invocationID self.hostID = hostID self.displayName = displayName self.address = address self.mode = mode self.selectedIdentityID = selectedIdentityID self.username = username self.selectedKeyID = selectedKeyID self.failureMessage = failureMessage } } nonisolated public struct AgentSSHAuthenticationAnswer: Hashable, Sendable { /// Existing identity when selected; nil means the inline Custom form. public var identityID: Identity.ID? public var identityName: String? public var username: String public var password: String? public var passphrase: String? public var keyID: SSHKeyRef.ID? /// Existing identities are bound; inline values create an Identity. public var save: Bool public init( identityID: Identity.ID? = nil, identityName: String? = nil, username: String, password: String? = nil, passphrase: String? = nil, keyID: SSHKeyRef.ID? = nil, save: Bool ) { self.identityID = identityID self.identityName = identityName self.username = username self.password = password self.passphrase = passphrase self.keyID = keyID self.save = save } } nonisolated public enum AgentSSHAuthenticationResponse: Hashable, Sendable { case submit(AgentSSHAuthenticationAnswer) case keyboardInteractive([String]) case cancelled } /// A host-key checkpoint raised locally while an Agent Exec or SFTP transport /// is being prepared. The observation is public SSH metadata; it is never /// serialized into the model-facing transcript or a tool result. nonisolated public struct AgentSSHHostKeyRequest: Identifiable, Hashable, Sendable { public var id: UUID = UUID() public var createdAt: Date = .now public var invocationID: AgentToolInvocation.ID public var hostID: Host.ID public var displayName: String public var address: String public var prompt: SSHHostKeyPrompt /// False for a pane-backed temporary target, which has no Known Hosts /// record to update. Its accepted key can only live for this connection. public var allowsPersistentTrust: Bool public init( id: UUID = UUID(), createdAt: Date = .now, invocationID: AgentToolInvocation.ID, hostID: Host.ID, displayName: String, address: String, prompt: SSHHostKeyPrompt, allowsPersistentTrust: Bool ) { self.id = id self.createdAt = createdAt self.invocationID = invocationID self.hostID = hostID self.displayName = displayName self.address = address self.prompt = prompt self.allowsPersistentTrust = allowsPersistentTrust } } nonisolated public enum AgentSSHHostKeyResponse: Hashable, Sendable { case deny case continueOnce case trustAndContinue } /// The two SSH checkpoints a run may reach, as one value the surface supplies. /// /// Termind's, not the runtime's. `AgentUserInteractionHandling` carries the /// model-authored question every adopter has; credentials and host-key trust are /// ours because hosts are, so they travel to the tools that need them through /// `AgentToolServices` instead of through a runtime protocol. /// /// Closures rather than a protocol because the supplier is always an actor-bound /// object — a conversation, the external-request coordinator — and the tools /// need neither its identity nor anything else it can do. /// /// Being outside the runtime is also what keeps this path silent. A credential /// prompt raises no `AgentEvent` and writes no durable record, because it never /// passes through `AgentEventChannel` at all — previously a wrapper had to /// deliberately forward these two methods without emitting. nonisolated public struct AgentSSHInteraction: Sendable { public typealias Authenticating = @Sendable (AgentSSHAuthenticationRequest) async -> AgentSSHAuthenticationResponse public typealias HostKeyConfirming = @Sendable (AgentSSHHostKeyRequest) async -> AgentSSHHostKeyResponse public var authenticate: Authenticating public var confirmHostKey: HostKeyConfirming /// What a run that cannot ask anybody does: refuse, rather than connect with /// whatever is lying around. public static let unavailable = AgentSSHInteraction( authenticate: { _ in .cancelled }, confirmHostKey: { _ in .deny } ) public func respondToChallenge( _ challenge: SSHKeyboardInteractiveChallenge, invocationID: AgentToolInvocation.ID ) async throws -> [String] { try Task.checkCancellation() let response = await authenticate( AgentSSHAuthenticationRequest( id: challenge.id, invocationID: invocationID, hostID: challenge.hostID, displayName: challenge.hostname, address: challenge.address, mode: .keyboardInteractive(challenge), username: challenge.username ) ) try Task.checkCancellation() guard case .keyboardInteractive(let answers) = response else { throw CancellationError() } return answers } public init(authenticate: @escaping Authenticating, confirmHostKey: @escaping HostKeyConfirming) { self.authenticate = authenticate self.confirmHostKey = confirmHostKey } } nonisolated extension AgentToolServiceKey where Service == AgentSSHInteraction { public static let sshInteraction = AgentToolServiceKey( "termind.ssh-interaction" ) } nonisolated extension AgentToolExecutionContext { /// The SSH prompt for this run, or one that refuses everything. /// /// Falling back rather than throwing: a host that registered the SSH tools /// and forgot the service has a bug, and the honest outcome of that bug is a /// connection that could not be authorized — not a crash inside a tool call. public var sshInteraction: AgentSSHInteraction { services[.sshInteraction] ?? .unavailable } }