170 lines
8.2 KiB
Swift
170 lines
8.2 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
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: .field("output"),
|
|
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
|
|
}
|
|
}
|