Files
Termind/Frameworks/TermindRemoteInfrastructure/Agent/SSHExecRunTool.swift
T
Kaede Akino d41f236220 refactor: centralize tool names in TermindToolName registry
Tool names are protocol surface: they appear in the MCP export list,
persisted disabled-tool preferences, audit records, and presentation
registries. Spelling them as literals at each of those sites let one
place drift from another with no compile error to catch it.

Add TermindToolName as the single source of truth for the 25 names
Termind owns, and route every non-documentation reference through it.
The registry lives in TermindApplication because presentation cannot
import TermindRemoteInfrastructure, and TermindApplication cannot depend
on it either.

Names owned by AgentKit (request_user_secret, present_plan, the
scratch_* build tools) and retired aliases (ssh_exec, ssh_wait) stay as
literals, as do the prose in MCP instructions and descriptor summaries.
The legacyToolNames compatibility list is a historical data contract and
is unchanged.

This also removes the sshExecRunToolName and sshExecStdinToolName
constants from TermindAgentPolicy, which the registry replaces.
2026-10-18 18:20:51 +08:00

167 lines
8.1 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",
layout: .init(singleLineHeader: true, boundsApprovalHeight: true, truncateStrategy: .tail),
present: { AgentToolDetailFormatting.genericItems($0.result, locale: $0.locale) },
presentArguments: presentArguments)
public let authentication: AgentSSHAuthenticationRegistry
public let pool: AgentConnectionPool
public let background: AgentBackgroundCommands
public let scope: UUID
public var truncateStrategy: AgentTruncateStrategy { .tail }
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(
TermindToolName.sshExecRun,
"""
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), combined output, exit_code, signal, \
stdin_closed and error. Model output keeps the latest complete lines within 64 KiB / 2000 lines. \
Truncated output is saved to <conversationID>/outputs/ssh-exec-<UUID>.txt in shared scratch; \
use scratch_read or scratch_search with that exact path. Capture failures are explicit. \
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 accumulator = context.makeOutputAccumulator {
"\(scope.uuidString.lowercased())/outputs/ssh-exec-\(UUID().uuidString.lowercased()).txt"
}
let stream = AgentCommandOutputStream(accumulator: accumulator) { out, _ in
var result = Self.result(
invocation,
.object([
"status": .string("running"), "output": .string(out),
]))
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 presentArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
typealias F = AgentToolDetailFormatting
var items: [AgentToolDetail.Item] = []
if let command = input.arguments["command"]?.stringValue {
items.append(
.text(
.init(
title: F.localized("Command", locale: input.locale), text: command,
style: .monospaced, isCollapsible: false)))
}
// 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
}
}