Files
Termind/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.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

159 lines
7.5 KiB
Swift

import AgentKit
import Foundation
import TermindApplication
import TermindDomain
nonisolated public struct SSHExecRunTool: AgentToolDefinition, AgentToolSchemaBuilding {
public static let presenter = AgentToolDetailPresenter(
id: "builtin.ssh_exec_run", present: SSHCommandPresentation.present,
presentArguments: presentArguments)
public let authentication: AgentSSHAuthenticationRegistry
public let pool: AgentConnectionPool
public let background: AgentBackgroundCommands
public let scope: UUID
public init(
authentication: AgentSSHAuthenticationRegistry, pool: AgentConnectionPool,
background: AgentBackgroundCommands, scope: UUID = UUID()
) {
self.authentication = authentication
self.pool = pool
self.background = background
self.scope = scope
}
public var descriptor: AgentToolDescriptor {
Self.descriptor(
"ssh_exec_run",
"""
Run a non-PTY SSH command. Discover host_id with list_hosts when needed; Termind handles login. \
run_in_background defaults to false: wait for completion without a runtime timeout and close stdin. \
Set true for long-lived commands or commands needing ssh_exec_stdin; return after confirmed startup. \
Returns handle, host_id, status (running/exited/failed), stdout, stderr, exit_code, signal, \
stdin_closed and error. Output keeps the latest 256 KiB per stream with an in-band truncation count. \
Handles belong to this conversation/session; finished results remain for ten minutes. \
Use ssh_exec_output for snapshots and ssh_exec_cancel for local cleanup. Never include plaintext secrets.
""",
properties: [
"host_id": Self.string(max: 64), "command": Self.string(max: 32_768),
"run_in_background": Self.boolean(),
], required: ["host_id", "command"], target: .host, approvalPolicy: .ask,
presentation: .init(
symbol: "terminal",
activity: .semanticArgument(key: "command", fallback: .sshCommand),
output: .streams,
actionKind: .run,
phaseKinds: [
"proposed": .sshPreparingCommand,
"waitingForApproval": .sshWaitingForCommand,
"waitingForUser": .sshWaitingForAuthentication,
"running": .sshConnecting,
"command": .sshRunningCommand,
"completed": .sshCompletedCommand,
"denied": .sshDeniedCommand,
"failed": .sshFailedCommand,
"interrupted": .sshInterruptedCommand,
],
messages: [
"proposed": "Preparing to run {activity} on {target}",
"waitingForApproval": "Waiting to run {activity} on {target}",
"waitingForUser": "Waiting to authenticate with {target}",
"running": "Connecting to {target}",
"command": "Running {activity} on {target}",
"completed": "Ran {activity} on {target}",
"denied": "Did not run {activity} on {target}",
"failed": "Failed to run {activity} on {target}",
"interrupted": "Stopped waiting for {activity} on {target}",
]
)
)
}
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
let arguments = try Arguments(invocation)
var checked = invocation
checked.targetHostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw AgentToolError.invalidArguments("command must not be empty.")
}
let assessment = CommandRiskClassifier.assess(command)
let safe = assessment.risk == .safe
return .init(
invocation: checked, approvalPolicy: safe ? .approve : .ask,
reasons: assessment.reasons, concurrency: safe ? .parallel : .sequential)
}
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
-> AgentToolResult
{
let arguments = try Arguments(invocation)
let hostID = try arguments.uuid("host_id")
let command = try arguments.string("command")
let isBackground = arguments.optionalBool("run_in_background") ?? false
let stream = AgentCommandOutputStream { out, error in
var result = Self.result(
invocation,
.object([
"status": .string("running"), "stdout": .string(out), "stderr": .string(error),
]))
result.metadata["presentation_phase"] = .string("command")
context.report(result)
}
let handle = try await background.start(
scope: scope, hostID: hostID, command: command, keepsStdinOpen: isBackground, stream: stream
) { session in
var connection = try await authentication.connectionContext(
hostID: hostID, invocationID: invocation.id, interaction: context.sshInteraction, pool: pool)
var retried = false
while true {
try Task.checkCancellation()
let ssh = connection.connection
do {
return try await pool.withExecLease(hostID: hostID, connection: ssh) {
try await ssh.executeSession(command, session: session, onOutput: stream.handler)
}
} catch SSHCommandExecutionError.connectionUnavailable where !retried && !session.started {
retried = true
await pool.disconnect(hostID: hostID)
connection.connection = try await authentication.openConnection(
hostID: hostID, authentication: connection.authentication, pool: pool)
}
}
}
if !isBackground {
let interjection: (@Sendable () async -> Void)?
if context.services[.sshInterjectionEnabled] == true {
interjection = { await context.whenUserInterjects() }
} else {
interjection = nil
}
do {
try await background.waitForCompletion(
handle, scope: scope, interjection: interjection)
} catch {
_ = try? await background.cancel(handle, scope: scope)
throw error
}
}
return SSHExecResults.snapshot(invocation, try await background.output(handle, scope: scope))
}
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
SSHCommandPresentation.present(input)
}
public static func presentArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items: [AgentToolDetail.Item] = []
if let command = input.arguments["command"]?.stringValue {
items.append(F.text("Command", command, locale: input.locale))
}
// Retained only for historical cards, not accepted by the executable schema.
F.appendField(
&items, "Working directory", input.arguments["cwd"]?.stringValue,
locale: input.locale, monospaced: true)
return items
}
}