Add interactive SSH authentication to agent tools
This commit is contained in:
1 parent
99be1d6885
commit
aa136347da
12 files changed
+881
-77
No files matched your search
+16
-14
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user