Files
Termind/TFMTests/ExternalRequestTests.swift

562 lines
21 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import AppKit
import Foundation
import Observation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite(.serialized)
@MainActor
struct ExternalAuthCoordinatorTests {
@Test(.requiresVisibleWindow) func interactiveResponsesAreScopedAndInformationalRoundsComplete() async throws {
let coordinator = ExternalAuthCoordinator()
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22,
username: "deploy", round: 1, name: "PAM", instruction: "OTP",
prompts: [.init(text: "Code", echo: false)]
)
let task = Task { try await coordinator.keyboardInteractive(challenge) }
for _ in 0..<1_000 where coordinator.current == nil { await Task.yield() }
#expect(coordinator.current?.id == challenge.id)
coordinator.respondSSH(.keyboardInteractive(["stale"]), requestID: UUID())
#expect(coordinator.current?.id == challenge.id)
coordinator.respondSSH(.keyboardInteractive(["123456"]), requestID: challenge.id)
#expect(try await task.value == ["123456"])
var notice = challenge
notice.id = UUID()
notice.prompts = []
#expect(try await coordinator.keyboardInteractive(notice) == [])
#expect(coordinator.current == nil)
}
@Test(.requiresVisibleWindow) func stoppingMCPPreservesAppAuthentication() async throws {
let coordinator = ExternalAuthCoordinator()
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22,
username: "deploy", round: 1, name: "PAM", instruction: "OTP",
prompts: [.init(text: "Code", echo: false)]
)
let appTask = Task { try await coordinator.keyboardInteractive(challenge) }
let externalTask = Task {
await coordinator.enqueue(authenticationRequest(name: "MCP"), clientName: "Termind", runID: UUID())
}
try await waitUntil { coordinator.pending.count == 2 }
coordinator.cancelExternalRequests()
#expect(await externalTask.value == .cancelled)
#expect(coordinator.pending.map(\.id) == [challenge.id])
coordinator.respondSSH(.keyboardInteractive(["123456"]), requestID: challenge.id)
#expect(try await appTask.value == ["123456"])
}
@Test
func authenticationWindowUsesFocusableNativeChrome() {
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 560, height: 240),
styleMask: [.titled, .closable, .miniaturizable, .resizable],
backing: .buffered,
defer: false
)
ExternalAuthWindowChrome.configure(window)
#expect(window.styleMask.contains(.titled))
#expect(window.styleMask.contains(.closable))
#expect(window.styleMask.contains(.miniaturizable))
#expect(window.styleMask.contains(.fullSizeContentView))
#expect(window.titleVisibility == .visible)
#expect(window.titlebarAppearsTransparent)
#expect(window.titlebarSeparatorStyle == .none)
#expect(window.canBecomeKey)
#expect(!window.isOpaque)
#expect(window.backgroundColor.alphaComponent == 0)
#expect(!window.isMovableByWindowBackground)
#expect(window.hasShadow)
#expect(window.level == .floating)
#expect(!window.hidesOnDeactivate)
#expect(window.collectionBehavior.contains(.moveToActiveSpace))
#expect(window.collectionBehavior.contains(.fullScreenAuxiliary))
#expect(window.standardWindowButton(.closeButton)?.isHidden == false)
#expect(window.standardWindowButton(.miniaturizeButton)?.isHidden == false)
#expect(window.standardWindowButton(.zoomButton)?.isHidden == false)
}
@Test(.requiresVisibleWindow)
func popupQueueResumesInFIFOOrder() async throws {
let coordinator = ExternalAuthCoordinator()
let runID = UUID()
let firstRequest = authenticationRequest(name: "First")
let secondRequest = authenticationRequest(name: "Second")
let first = Task {
await coordinator.enqueue(firstRequest, clientName: "Claude", runID: runID)
}
let second = Task {
await coordinator.enqueue(secondRequest, clientName: "Codex", runID: runID)
}
try await waitUntil { coordinator.pending.count == 2 }
#expect(coordinator.current?.request.id == firstRequest.id)
#expect(coordinator.current?.clientName == "Claude")
let answer = AgentSSHAuthenticationAnswer(
username: "deploy", password: "secret", save: false
)
coordinator.respondSSH(.submit(answer))
#expect(await first.value == .submit(answer))
#expect(coordinator.current?.request.id == secondRequest.id)
coordinator.respondSSH(.cancelled)
#expect(await second.value == .cancelled)
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func protectedKeyCanRaisePassphraseImmediatelyAfterIdentity() async throws {
let coordinator = ExternalAuthCoordinator()
let handler = coordinator.handler(clientName: "Codex", runID: UUID())
let invocationID = UUID()
let hostID = UUID()
let keyID = UUID()
let identity = AgentSSHAuthenticationRequest(
invocationID: invocationID, hostID: hostID,
displayName: "Server", address: "server.example",
mode: .identity, username: "", failureMessage: nil
)
let flow = Task {
let first = await handler.sshInteraction.authenticate(identity)
guard case .submit = first else { return first }
return await handler.sshInteraction.authenticate(
AgentSSHAuthenticationRequest(
invocationID: invocationID, hostID: hostID,
displayName: "Server", address: "server.example",
mode: .passphrase(keyID: keyID), username: "deploy",
selectedKeyID: keyID, failureMessage: nil
)
)
}
try await waitUntil { coordinator.current?.id == identity.id }
coordinator.respondSSH(
.submit(
AgentSSHAuthenticationAnswer(
username: "deploy", keyID: keyID, save: false
)))
try await waitUntil {
guard case .ssh(let request)? = coordinator.current?.request,
case .passphrase(let presentedKeyID) = request.mode
else { return false }
return presentedKeyID == keyID
}
guard case .ssh(let passphrase)? = coordinator.current?.request else {
Issue.record("Expected a Passphrase request")
return
}
#expect(passphrase.username == "deploy")
#expect(passphrase.selectedKeyID == keyID)
let answer = AgentSSHAuthenticationAnswer(
username: "deploy", passphrase: "one-use", keyID: keyID, save: false
)
coordinator.respondSSH(.submit(answer))
#expect(await flow.value == .submit(answer))
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func closingThePopupCancelsEveryWaiter() async throws {
let coordinator = ExternalAuthCoordinator()
let task = Task {
await coordinator.enqueue(
authenticationRequest(name: "Host"), clientName: "Client", runID: UUID()
)
}
try await waitUntil { coordinator.current != nil }
coordinator.windowClosed()
#expect(await task.value == .cancelled)
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func staleWindowDisappearanceCannotCancelANewerPassphraseRequest() async throws {
let coordinator = ExternalAuthCoordinator()
let firstRequest = authenticationRequest(name: "First")
let first = Task {
await coordinator.enqueue(
firstRequest, clientName: "Client", runID: UUID()
)
}
try await waitUntil { coordinator.current?.id == firstRequest.id }
coordinator.respondSSH(.cancelled)
#expect(await first.value == .cancelled)
let keyID = UUID()
let passphraseRequest = AgentSSHAuthenticationRequest(
invocationID: UUID(), hostID: UUID(),
displayName: "Server", address: "server.example",
mode: .passphrase(keyID: keyID), username: "deploy",
selectedKeyID: keyID, failureMessage: nil
)
let passphrase = Task {
await coordinator.enqueue(
passphraseRequest, clientName: "Client", runID: UUID()
)
}
try await waitUntil { coordinator.current?.id == passphraseRequest.id }
// The old scene completes its close after the new one has opened.
coordinator.windowClosed(presenting: firstRequest.id)
#expect(coordinator.current?.id == passphraseRequest.id)
let answer = AgentSSHAuthenticationAnswer(
username: "deploy", passphrase: "one-use",
keyID: keyID, save: false
)
coordinator.respondSSH(.submit(answer))
#expect(await passphrase.value == .submit(answer))
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func secretRequestsUseTheSamePopupQueueAndRejectOrdinaryQuestions() async throws {
let coordinator = ExternalAuthCoordinator()
let runID = UUID()
let request = AgentUserInputRequest(
questions: [AgentUserQuestion(id: "secret", prompt: "Password", kind: .secret)],
purpose: "sudo"
)
let task = Task {
await coordinator.enqueue(request, clientName: "Claude", runID: runID)
}
try await waitUntil {
if case .secret? = coordinator.current?.request { return true }
return false
}
let response = AgentUserInputResponse.answered([
AgentUserInputAnswer(questionID: "secret", value: .secret(agentSecret("value")))
])
coordinator.respondSecret(response)
#expect(await task.value == response)
let ordinary = AgentUserInputRequest(
questions: [AgentUserQuestion(id: "text", prompt: "Name", kind: .text)]
)
#expect(
await coordinator.enqueue(ordinary, clientName: "Claude", runID: runID)
== .cancelled
)
}
@Test(.requiresVisibleWindow)
func cancellingAQueuedSessionDoesNotRepresentTheCurrentPopup() async throws {
let coordinator = ExternalAuthCoordinator()
let currentRunID = UUID()
let queuedRunID = UUID()
let current = Task {
await coordinator.enqueue(
authenticationRequest(name: "Current"), clientName: "Claude",
runID: currentRunID
)
}
let queued = Task {
await coordinator.enqueue(
authenticationRequest(name: "Queued"), clientName: "Codex",
runID: queuedRunID
)
}
try await waitUntil { coordinator.pending.count == 2 }
let revision = coordinator.presentationRevision
coordinator.cancel(runID: queuedRunID)
#expect(await queued.value == .cancelled)
#expect(coordinator.current?.clientName == "Claude")
#expect(coordinator.presentationRevision == revision)
coordinator.cancel(runID: currentRunID)
#expect(await current.value == .cancelled)
}
@Test(.requiresVisibleWindow)
func visibleSecretTimesOutAndLeavesNoPendingPopup() async throws {
let coordinator = ExternalAuthCoordinator()
let request = secretRequest(timeout: .milliseconds(40))
let task = Task {
await coordinator.enqueue(request, clientName: "Claude", runID: UUID())
}
try await waitUntil { coordinator.current?.id == request.id }
#expect(await task.value == .timedOut)
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func queuedSecretDoesNotSpendItsTimeoutBehindSSHPrompt() async throws {
let coordinator = ExternalAuthCoordinator()
let sshRequest = authenticationRequest(name: "Current")
let request = secretRequest(timeout: .milliseconds(40))
let ssh = Task {
await coordinator.enqueue(sshRequest, clientName: "Claude", runID: UUID())
}
try await waitUntil { coordinator.current?.id == sshRequest.id }
let secret = Task {
await coordinator.enqueue(request, clientName: "Codex", runID: UUID())
}
try await waitUntil { coordinator.pending.count == 2 }
try await Task.sleep(for: .milliseconds(80))
#expect(coordinator.current?.id == sshRequest.id)
#expect(coordinator.pending.contains { $0.id == request.id })
coordinator.respondSSH(.cancelled)
#expect(await ssh.value == .cancelled)
#expect(coordinator.current?.id == request.id)
#expect(await secret.value == .timedOut)
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func answeredSecretCannotLaterRetireANewerPopup() async throws {
let coordinator = ExternalAuthCoordinator()
let request = secretRequest(timeout: .milliseconds(40))
let secret = Task {
await coordinator.enqueue(request, clientName: "Claude", runID: UUID())
}
try await waitUntil { coordinator.current?.id == request.id }
coordinator.respondSecret(
.answered([
AgentUserInputAnswer(
questionID: "secret", value: .secret(agentSecret("value"))
)
]))
guard case .answered = await secret.value else {
Issue.record("expected the secret request to be answered")
return
}
let sshRequest = authenticationRequest(name: "Next")
let ssh = Task {
await coordinator.enqueue(sshRequest, clientName: "Codex", runID: UUID())
}
try await waitUntil { coordinator.current?.id == sshRequest.id }
try await Task.sleep(for: .milliseconds(80))
#expect(coordinator.current?.id == sshRequest.id)
coordinator.respondSSH(.cancelled)
#expect(await ssh.value == .cancelled)
}
@Test(.requiresVisibleWindow)
func hostKeyUsesPopupQueueAndReturnsSelectedTrustScope() async throws {
let coordinator = ExternalAuthCoordinator()
let request = hostKeyRequest()
let task = Task {
await coordinator.enqueue(
request, clientName: "Claude Code", runID: UUID()
)
}
try await waitUntil {
if case .hostKey? = coordinator.current?.request { return true }
return false
}
#expect(coordinator.current?.clientName == "Claude Code")
coordinator.respondHostKey(.trustAndContinue)
#expect(await task.value == .trustAndContinue)
#expect(coordinator.pending.isEmpty)
}
@Test(.requiresVisibleWindow)
func closingHostKeyPopupDeniesTheWaitingCall() async throws {
let coordinator = ExternalAuthCoordinator()
let task = Task {
await coordinator.enqueue(
hostKeyRequest(), clientName: "Codex", runID: UUID()
)
}
try await waitUntil {
if case .hostKey? = coordinator.current?.request { return true }
return false
}
coordinator.windowClosed()
#expect(await task.value == .deny)
#expect(coordinator.pending.isEmpty)
}
}
@Suite(.serialized)
@MainActor
struct ExternalToolCallHistoryTests {
@Test(arguments: [nil, "uname"])
func clearingHistoryInvalidatesObservedEntriesImmediately(filter: String?) {
let entry = ExternalToolCall(
runID: UUID(), clientName: "Claude Code", toolName: "ssh_exec",
summary: "uname -a", risk: .readOnly,
startedAt: Date(timeIntervalSince1970: 1)
)
let store = ToolAuditModel(entries: [entry])
let invalidated = ObservationInvalidationFlag()
func observedEntries() -> [ExternalToolCall] {
filter.map { store.entries(matching: $0) } ?? store.entries()
}
withObservationTracking {
#expect(observedEntries().map(\.id) == [entry.id])
} onChange: {
invalidated.value = true
}
store.clear()
#expect(invalidated.value)
#expect(observedEntries().isEmpty)
}
@Test
func sftpBodiesAndSSHOutputNeverEnterHistory() async throws {
let channel = AgentEventChannel(repository: InMemoryAgentRunRepository())
let store = ToolAuditModel()
store.consume(channel)
let runID = UUID()
store.register(runID: runID, clientName: "Claude Code")
let write = invocation(
runID: runID, name: "sftp_write",
arguments: ["path": .string("/srv/config"), "content": .string("TOP-SECRET")]
)
channel.emit(.toolProposed(write, descriptor(name: "sftp_write", readOnly: false)))
channel.emit(
.toolFinished(
write,
untrustedResult(
callID: write.call.id, payload: .object(["bytes": .number(10)])
)))
let exec = invocation(
runID: runID, name: "ssh_exec", arguments: ["command": .string("uname -a")]
)
channel.emit(.toolProposed(exec, descriptor(name: "ssh_exec", readOnly: false)))
channel.emit(
.toolFinished(
exec,
untrustedResult(
callID: exec.call.id,
payload: .object(["stdout": .string("PRIVATE OUTPUT"), "exit_code": .number(0)])
)))
try await waitUntil {
let rows = store.entries()
return rows.count == 2 && rows.allSatisfy { $0.status != .running }
}
let rows = store.entries()
let writeRow = try #require(rows.first { $0.toolName == "sftp_write" })
#expect(writeRow.summary == "/srv/config")
#expect(writeRow.bytesWritten == 10)
#expect(!writeRow.summary.contains("TOP-SECRET"))
let execRow = try #require(rows.first { $0.toolName == "ssh_exec" })
#expect(execRow.summary == "uname -a")
#expect(execRow.exitCode == 0)
#expect(!execRow.summary.contains("PRIVATE OUTPUT"))
}
@Test
func inMemoryHistoryKeepsTheNewestTwoThousandRows() {
let start = Date(timeIntervalSince1970: 1_000)
let entries = (0...ToolAuditModel.globalLimit).map { index in
ExternalToolCall(
runID: UUID(), clientName: "Client", toolName: "list_hosts",
summary: "row-\(index)", risk: .readOnly,
startedAt: start.addingTimeInterval(Double(index))
)
}
let store = ToolAuditModel(entries: entries)
let kept = store.entries()
#expect(kept.count == ToolAuditModel.globalLimit)
#expect(kept.first?.summary == "row-2000")
#expect(kept.last?.summary == "row-1")
}
}
nonisolated private final class ObservationInvalidationFlag: @unchecked Sendable {
var value = false
}
private func authenticationRequest(name: String) -> AgentSSHAuthenticationRequest {
AgentSSHAuthenticationRequest(
invocationID: UUID(), hostID: UUID(), displayName: name,
address: "host.example", mode: .identity, username: "root"
)
}
private func secretRequest(timeout: Duration) -> AgentUserInputRequest {
AgentUserInputRequest(
questions: [AgentUserQuestion(id: "secret", prompt: "Secret", kind: .secret)],
purpose: "test", timeout: timeout
)
}
private func hostKeyRequest() -> AgentSSHHostKeyRequest {
let observation = SSHHostKeyObservation(
hostname: "host.example", port: 22, algorithm: .ed25519,
fingerprint: "SHA256:new"
)
return AgentSSHHostKeyRequest(
invocationID: UUID(), hostID: UUID(), displayName: "Host",
address: "host.example",
prompt: SSHHostKeyPrompt(observation: observation, kind: .firstUse),
allowsPersistentTrust: true
)
}
private func invocation(
runID: UUID, name: String, arguments: [String: AgentJSONValue]
) -> AgentToolInvocation {
AgentToolInvocation(
runID: runID,
call: AgentToolCall(id: UUID().uuidString, name: name, arguments: .object(arguments)),
targetHostID: UUID()
)
}
private func descriptor(name: String, readOnly: Bool) -> AgentToolDescriptor {
AgentToolDescriptor(
name: name, summary: name,
inputSchema: .object(["type": .string("object")]), target: .host,
approvalPolicy: readOnly ? .approve : .ask
)
}
private func untrustedResult(callID: String, payload: AgentJSONValue) -> AgentToolResult {
AgentToolResult(
callID: callID,
content: AgentToolResult.untrustedDataOpeningMarker + payload.encodedString
+ AgentToolResult.untrustedDataClosingMarker,
metadata: ["untrusted": .bool(true)]
)
}
@MainActor
private func waitUntil(
_ condition: @MainActor () -> Bool
) async throws {
for _ in 0..<100 {
if condition() { return }
await Task.yield()
}
throw ExternalRequestTestFailure.timedOut
}
private enum ExternalRequestTestFailure: Error { case timedOut }