Files
Termind/TFM/Features/ExternalRequests/ExternalAuthWindow.swift
T
Kaede Akino ebeb0027d4 feat(ssh)!: redesign command execution tools
Split command execution into run, output, stdin, cancel and list tools for the assistant and MCP. Preserve bounded output tails and use conversation-scoped handles and secret storage with AgentKit 1.20.0.

Add the built-in ssh-exec skill with timestamped log redirection and text/secret stdin workflows. Expand long tool output without adjacent copy controls, update localized presentation, and refresh regression coverage and the website tool name.

BREAKING CHANGE: Replace ssh_exec and ssh_wait with ssh_exec_run and ssh_exec_output. Move stdin to ssh_exec_stdin and remove execution timeout and cwd arguments. Use run_in_background to control foreground execution.
2026-10-17 12:26:48 +08:00

322 lines
12 KiB
Swift

import AgentKit
import AppKit
import SwiftUI
import TermindApplication
import TermindPresentation
struct ExternalAuthWindowPresenter: View {
static let sceneID = "external-ssh-authentication"
@Environment(AppModel.self) private var app
@Environment(\.openWindow) private var openWindow
var body: some View {
Color.clear
.frame(width: 0, height: 0)
.accessibilityHidden(true)
.onChange(of: app.externalAuthentication.presentationRevision, initial: true) {
guard app.externalAuthentication.current != nil else { return }
openWindow(id: Self.sceneID)
}
}
}
struct ExternalAuthWindow: View {
@Environment(AppModel.self) private var app
@Environment(\.dismissWindow) private var dismissWindow
@State private var idleDismissal: Task<Void, Never>?
@State private var displayedRequestID: UUID?
@State private var displayedPending: ExternalAuthCoordinator.Pending?
@State private var isClosingAfterIdle = false
private var pending: ExternalAuthCoordinator.Pending? {
app.externalAuthentication.current
}
var body: some View {
Group {
if let visiblePending {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
Text(AppStrings.MCP.requestedBy(visiblePending.clientName))
.font(.caption)
.foregroundStyle(.secondary)
requestView(visiblePending)
}
// The window glass is already the visual container. Keep one
// generous inset instead of nesting material cards.
.padding(Metrics.Space.xxl)
} else {
Color.clear
}
}
// Authentication requests vary substantially in height. A passphrase
// request has only one field, while identity and secret requests can
// contain several rows. Let the content establish the window height so
// compact requests do not inherit the empty space needed by larger ones.
.fixedSize(horizontal: false, vertical: true)
.frame(minWidth: 520)
// A background does not contribute an ideal size. The former ZStack's
// flexible Color accepted the current window height and fed it back to
// `.windowResizability(.contentSize)`, preventing shorter Passphrase
// requests from shrinking the window after a taller Identity form.
.background {
Color.clear
.termindGlassSurface(cornerRadius: Metrics.Radius.large)
.ignoresSafeArea()
.allowsHitTesting(false)
.accessibilityHidden(true)
}
// SwiftUI otherwise supplies its own opaque window background below
// the glass view. Clearing both container and NSWindow backgrounds is
// what leaves a single, genuine material surface.
.containerBackground(.clear, for: .window)
.background {
ExternalAuthWindowChromeBridge(presentationID: pending?.id)
}
.onAppear {
isClosingAfterIdle = false
displayedRequestID = pending?.id
displayedPending = pending
if let id = pending?.id { app.externalAuthentication.markPresented(id) }
}
.onChange(of: pending?.id, initial: true) {
if !isClosingAfterIdle, let pending {
displayedRequestID = pending.id
displayedPending = pending
app.externalAuthentication.markPresented(pending.id)
}
updateIdleDismissal()
}
.onDisappear {
idleDismissal?.cancel()
if !isClosingAfterIdle, app.externalAuthentication.current != nil {
app.externalAuthentication.windowClosed(presenting: displayedRequestID)
}
}
}
/// Keep the current form visible across the brief Identity-to-Passphrase
/// hand-off. The next request replaces it directly; completion closes the
/// window without an intermediate animation.
private var visiblePending: ExternalAuthCoordinator.Pending? {
pending ?? displayedPending
}
@ViewBuilder
private func requestView(_ pending: ExternalAuthCoordinator.Pending) -> some View {
switch pending.request {
case .ssh(let request):
if let signing = request.securityKey {
AgentSecurityKeySigningCard(request: signing) {
app.externalAuthentication.respondSSH($0, requestID: request.id)
}
} else {
DesktopSSHAuthenticationForm(
prompt: request.journeyPrompt,
submit: { identityID, username, password, passphrase, keyID, save in
app.externalAuthentication.respondSSH(
.submit(
AgentSSHAuthenticationAnswer(
identityID: identityID,
identityName: identityID.flatMap {
app.hostLibrary.identity($0)?.name
},
username: username, password: password,
passphrase: passphrase, keyID: keyID,
save: request.requiresPassphrase ? false : save
)
), requestID: request.id
)
},
cancel: { app.externalAuthentication.respondSSH(.cancelled, requestID: request.id) },
usesContainerChrome: false,
respondKeyboardInteractive: {
app.externalAuthentication.respondSSH(.keyboardInteractive($0), requestID: request.id)
}
)
.id(pending.id)
}
case .secret(let request):
ExternalSecretRequestForm(
request: request,
submit: { response in
app.externalAuthentication.respondSecret(response)
}
)
.id(pending.id)
case .hostKey(let request):
DesktopSSHHostKeyConfirmationForm(
prompt: request.prompt,
displayAddress: request.address,
proceed: {
app.externalAuthentication.respondHostKey(.continueOnce)
},
trust: request.allowsPersistentTrust
? {
app.externalAuthentication.respondHostKey(.trustAndContinue)
} : nil,
deny: {
app.externalAuthentication.respondHostKey(.deny)
},
usesContainerChrome: false
)
.id(pending.id)
}
}
private func updateIdleDismissal() {
idleDismissal?.cancel()
idleDismissal = nil
guard pending == nil else { return }
// An SSH flow can answer Identity, briefly empty the coordinator, and
// immediately enqueue Passphrase for the selected protected key. Do not
// interpret that hand-off as completion: give the resumed resolver one
// main-actor turn to publish its next checkpoint before closing. The
// small bounded grace also avoids a visible close/reopen flash.
idleDismissal = Task { @MainActor in
do {
try await Task.sleep(for: .milliseconds(50))
} catch {
return
}
guard app.externalAuthentication.current == nil else { return }
isClosingAfterIdle = true
dismissWindow(id: ExternalAuthWindowPresenter.sceneID)
}
}
}
/// Native chrome and presentation policy for the external authentication scene.
@MainActor
enum ExternalAuthWindowChrome {
static func configure(_ window: NSWindow, presentsRequest: Bool = false) {
window.styleMask.insert([
.titled, .closable, .miniaturizable, .fullSizeContentView,
])
window.title = String(localized: AppStrings.Connections.authenticationRequest)
window.titleVisibility = .visible
window.titlebarAppearsTransparent = true
window.titlebarSeparatorStyle = .none
window.isOpaque = false
window.backgroundColor = .clear
window.isMovableByWindowBackground = false
window.hasShadow = true
window.level = .floating
window.hidesOnDeactivate = false
window.collectionBehavior.formUnion([.moveToActiveSpace, .fullScreenAuxiliary])
for button in [
NSWindow.ButtonType.closeButton,
NSWindow.ButtonType.miniaturizeButton,
NSWindow.ButtonType.zoomButton,
] {
window.standardWindowButton(button)?.isHidden = false
}
if presentsRequest {
NSApp.activate(ignoringOtherApps: true)
window.orderFrontRegardless()
window.makeKeyAndOrderFront(nil)
// FocusState may resolve during the same layout pass that attached
// this bridge. Reassert key status once the SecureField exists.
Task { @MainActor [weak window] in window?.makeKey() }
}
}
}
private struct ExternalAuthWindowChromeBridge: NSViewRepresentable {
let presentationID: UUID?
func makeNSView(context: Context) -> ExternalAuthWindowChromeBridgeView {
ExternalAuthWindowChromeBridgeView()
}
func updateNSView(_ view: ExternalAuthWindowChromeBridgeView, context: Context) {
view.configureWindow(presentationID: presentationID)
}
}
private final class ExternalAuthWindowChromeBridgeView: NSView {
private var presentedRequestID: UUID?
override func viewDidMoveToWindow() {
super.viewDidMoveToWindow()
configureWindow(presentationID: nil)
}
func configureWindow(presentationID: UUID?) {
guard let window else { return }
let presentsRequest = presentationID != nil && presentationID != presentedRequestID
presentedRequestID = presentationID
ExternalAuthWindowChrome.configure(window, presentsRequest: presentsRequest)
}
}
/// Secret-only checkpoint used by the external `request_user_secret` tool.
private struct ExternalSecretRequestForm: View {
let request: AgentUserInputRequest
let submit: (AgentUserInputResponse) -> Void
@State private var secret = ""
@FocusState private var isFocused: Bool
private var question: AgentUserQuestion? { request.questions.first }
var body: some View {
VStack(alignment: .leading, spacing: Metrics.Space.m) {
Label(AppStrings.MCP.secretInput.swiftUIKey, systemImage: "key.fill")
.font(.headline)
if let question {
Text(question.prompt)
.font(.callout)
.fixedSize(horizontal: false, vertical: true)
}
if !request.purpose.isEmpty {
LabeledContent(AppStrings.MCP.purpose.swiftUIKey, value: request.purpose)
}
RevealableSecretField(AppStrings.Keychain.secret, text: $secret, appearance: .roundedBorder)
.focused($isFocused)
Text(
AppStrings.MCP.secretHandleExplanation
)
.font(.caption)
.foregroundStyle(.secondary)
HStack {
Button(AppStrings.Common.cancel.swiftUIKey) { cancel() }
.keyboardShortcut(.cancelAction)
Spacer()
Button(AppStrings.Common.continue.swiftUIKey) { submitSecret() }
.transcriptProminentButtonStyle()
.keyboardShortcut(.defaultAction)
.disabled(secret.isEmpty || question == nil)
}
}
.activeFormKeyboardActions(
isDefaultEnabled: !secret.isEmpty && question != nil,
onDefault: submitSecret,
onCancel: cancel
)
.onAppear { isFocused = true }
.onDisappear { secret = "" }
}
private func submitSecret() {
guard let question, !secret.isEmpty else { return }
var bytes = Data(secret.utf8)
secret = ""
submit(
.answered([
AgentUserInputAnswer(
questionID: question.id,
value: .secret(AgentSecret(consumingUTF8: &bytes))
)
]))
}
private func cancel() {
secret = ""
submit(.cancelled)
}
}