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.
246 lines
12 KiB
Swift
246 lines
12 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
nonisolated public struct SFTPCopyTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_copy", present: present, presentArguments: presentArguments
|
|
)
|
|
public let hosts: AgentHostResolver
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
|
|
public init(
|
|
hosts: AgentHostResolver, authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool
|
|
) {
|
|
self.hosts = hosts
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.sftpCopy,
|
|
"Copy a regular file or directory recursively between hosts using streamed SFTP. "
|
|
+ "Preserves names and empty directories, not permissions or timestamps. Never deletes the source.",
|
|
properties: [
|
|
"source_host_id": Self.string(), "source_path": Self.string(max: 4_096),
|
|
"destination_host_id": Self.string(),
|
|
"destination_path": Self.described(Self.string(max: 4_096), "An existing destination directory."),
|
|
"conflict_policy": Self.described(
|
|
Self.enumeration(["skip", "replace", "keep_both"]),
|
|
"Defaults to skip. Same-name directories merge. Replace may delete conflicting target directories."
|
|
),
|
|
],
|
|
required: ["source_host_id", "source_path", "destination_host_id", "destination_path"],
|
|
target: .host, approvalPolicy: .ask, concurrency: .sequential,
|
|
presentation: .init(
|
|
symbol: "doc.on.doc", activity: .semanticArgument(key: "source_path", fallback: .remoteFile),
|
|
output: .json, actionKind: .copy
|
|
)
|
|
)
|
|
}
|
|
|
|
private struct Request: Sendable {
|
|
var sourceHostID: UUID
|
|
var sourcePath: String
|
|
var destinationHostID: UUID
|
|
var destinationPath: String
|
|
var policy: TransferConflictPolicy
|
|
|
|
init(_ invocation: AgentToolInvocation) throws {
|
|
let arguments = try Arguments(invocation)
|
|
sourceHostID = try arguments.uuid("source_host_id")
|
|
destinationHostID = try arguments.uuid("destination_host_id")
|
|
sourcePath = try AgentToolSupport.normalizeRemotePath(arguments.string("source_path"))
|
|
destinationPath = try AgentToolSupport.normalizeRemotePath(arguments.string("destination_path"))
|
|
switch arguments.optionalString("conflict_policy") ?? "skip" {
|
|
case "skip": policy = .skip
|
|
case "replace": policy = .replace
|
|
case "keep_both": policy = .keepBoth
|
|
default: throw AgentToolError.invalidArguments("Unknown SFTP conflict policy.")
|
|
}
|
|
let name = (sourcePath as NSString).lastPathComponent
|
|
let target = destinationPath == "/" ? "/\(name)" : destinationPath + "/" + name
|
|
guard sourcePath != "/", sourceHostID != destinationHostID || target != sourcePath else {
|
|
throw AgentToolError.invalidPath
|
|
}
|
|
}
|
|
}
|
|
|
|
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
|
let request = try Request(invocation)
|
|
let listing = await hosts.listing().hosts
|
|
guard let source = listing.first(where: { $0.id == request.sourceHostID }),
|
|
let destination = listing.first(where: { $0.id == request.destinationHostID })
|
|
else { throw AgentToolError.invalidHost }
|
|
var checked = invocation
|
|
checked.targetHostID = request.destinationHostID
|
|
return AgentToolPreflight(
|
|
invocation: checked, approvalPolicy: .ask,
|
|
reasons: [
|
|
"\(source.displayName) (\(source.id)): \(request.sourcePath) → "
|
|
+ "\(destination.displayName) (\(destination.id)): \(request.destinationPath)"
|
|
],
|
|
executionMetadata: [
|
|
"source_host_name": .string(source.displayName),
|
|
"destination_host_name": .string(destination.displayName),
|
|
]
|
|
)
|
|
}
|
|
|
|
public func execute(
|
|
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
|
|
) async throws -> AgentToolResult {
|
|
let request = try Request(invocation)
|
|
let cancellation = TransferCancellationToken()
|
|
return try await withTaskCancellationHandler {
|
|
let permit = try await connections.sftpConcurrencyScheduler.acquire(
|
|
anchorHostID: request.destinationHostID,
|
|
hostIDs: [request.sourceHostID, request.destinationHostID],
|
|
source: .agentRun(context.runID),
|
|
byteCount: nil
|
|
)
|
|
do {
|
|
let result = try await authentication.withSFTP(
|
|
hostID: request.sourceHostID, invocationID: invocation.id,
|
|
interaction: context.sshInteraction, pool: connections, cancellation: cancellation,
|
|
runID: context.runID, schedulesDataTransfer: true, concurrencyPermit: permit
|
|
) { source in
|
|
if request.sourceHostID == request.destinationHostID,
|
|
permit.schedulingMode == .channelsOnly
|
|
{
|
|
return await performCopy(
|
|
source: source, destination: source,
|
|
request: request, invocation: invocation,
|
|
context: context, cancellation: cancellation)
|
|
}
|
|
return try await authentication.withSFTP(
|
|
hostID: request.destinationHostID, invocationID: invocation.id,
|
|
interaction: context.sshInteraction, pool: connections, cancellation: cancellation,
|
|
runID: context.runID, schedulesDataTransfer: true, concurrencyPermit: permit
|
|
) { destination in
|
|
await performCopy(
|
|
source: source, destination: destination,
|
|
request: request, invocation: invocation,
|
|
context: context, cancellation: cancellation)
|
|
}
|
|
}
|
|
await permit.release()
|
|
return result
|
|
} catch {
|
|
await permit.release()
|
|
throw error
|
|
}
|
|
} onCancel: {
|
|
cancellation.cancel()
|
|
}
|
|
}
|
|
|
|
private func performCopy(
|
|
source: any SFTPChannelProtocol,
|
|
destination: any SFTPChannelProtocol,
|
|
request: Request,
|
|
invocation: AgentToolInvocation,
|
|
context: AgentToolExecutionContext,
|
|
cancellation: TransferCancellationToken
|
|
) async -> AgentToolResult {
|
|
let outcome = await SFTPRemoteCopyOperation.run(
|
|
sourcePath: request.sourcePath, destinationPath: request.destinationPath,
|
|
sameHost: request.sourceHostID == request.destinationHostID,
|
|
source: source, destination: destination, policy: request.policy,
|
|
cancellation: cancellation
|
|
) { outcome, progress in
|
|
context.report(Self.copyResult(invocation, request, context, outcome, progress: progress))
|
|
}
|
|
return Self.copyResult(invocation, request, context, outcome)
|
|
}
|
|
|
|
private static func copyResult(
|
|
_ invocation: AgentToolInvocation, _ request: Request, _ context: AgentToolExecutionContext,
|
|
_ outcome: SFTPRemoteCopyOperation.Outcome, progress: SFTPProgress? = nil
|
|
) -> AgentToolResult {
|
|
var object: [String: AgentJSONValue] = [
|
|
"source_host_id": .string(request.sourceHostID.uuidString), "source_path": .string(request.sourcePath),
|
|
"destination_host_id": .string(request.destinationHostID.uuidString),
|
|
"destination_path": .string(request.destinationPath),
|
|
"actual_destination_path": .string(outcome.actualDestinationPath),
|
|
"conflict_policy": .string(request.policy == .keepBoth ? "keep_both" : request.policy.rawValue),
|
|
"copied_files": .number(Double(outcome.copiedFiles)),
|
|
"created_directories": .number(Double(outcome.createdDirectories)),
|
|
"skipped_items": .number(Double(outcome.skippedItems)), "bytes": .number(Double(outcome.bytes)),
|
|
"completed_bytes": .number(Double(outcome.bytes + (progress?.completedBytes ?? 0))),
|
|
"total_bytes": .number(Double(outcome.totalBytes)),
|
|
"bytes_per_second": .number(Double(progress?.bytesPerSecond ?? 0)),
|
|
]
|
|
for key in ["source_host_name", "destination_host_name"] { object[key] = context.preflightMetadata[key] }
|
|
if let error = outcome.error { object["error"] = .string(error) }
|
|
if let path = outcome.failedPath { object["failed_path"] = .string(path) }
|
|
var result = Self.result(invocation, .object(object))
|
|
result.isError = outcome.error != nil
|
|
return result
|
|
}
|
|
|
|
private static func fields(_ object: [String: AgentJSONValue], locale: Locale) -> [AgentToolDetail.Item] {
|
|
func field(_ label: String.LocalizationValue, _ value: String) -> AgentToolDetail.Item {
|
|
.field(
|
|
.init(label: AppLocalization.string(label, locale: locale), value: value, isMonospaced: true))
|
|
}
|
|
var items: [AgentToolDetail.Item] = []
|
|
let values: [(String.LocalizationValue, String)] = [
|
|
("Source host", "source_host_id"), ("Source path", "source_path"),
|
|
("Destination host", "destination_host_id"), ("Destination path", "destination_path"),
|
|
("Actual destination", "actual_destination_path"),
|
|
]
|
|
for (label, key) in values {
|
|
if let value = object[key]?.stringValue {
|
|
let name =
|
|
key == "source_host_id"
|
|
? object["source_host_name"]?.stringValue
|
|
: key == "destination_host_id" ? object["destination_host_name"]?.stringValue : nil
|
|
items.append(field(label, name.map { "\($0) (\(value))" } ?? value))
|
|
}
|
|
}
|
|
let policy = object["conflict_policy"]?.stringValue ?? "skip"
|
|
let policyLabel: String.LocalizationValue =
|
|
policy == "replace" ? "Replace" : policy == "keep_both" ? "Keep Both" : "Skip"
|
|
items.append(field("Conflict policy", AppLocalization.string(policyLabel, locale: locale)))
|
|
if policy == "replace" {
|
|
items.append(
|
|
.message(
|
|
AppLocalization.string("Overwrite may delete conflicting target directories.", locale: locale),
|
|
.warning
|
|
))
|
|
}
|
|
let counts: [(String.LocalizationValue, String)] = [
|
|
("Copied files", "copied_files"), ("Created directories", "created_directories"),
|
|
("Skipped items", "skipped_items"),
|
|
]
|
|
for (label, key) in counts {
|
|
if let value = object[key]?.integerValue { items.append(field(label, String(value))) }
|
|
}
|
|
for (label, key) in [
|
|
(String.LocalizationValue("Completed"), "completed_bytes"), ("Total", "total_bytes"),
|
|
] {
|
|
if let value = object[key]?.integerValue {
|
|
items.append(field(label, AgentToolDetailFormatting.byteCount(value)))
|
|
}
|
|
}
|
|
if let value = object["bytes_per_second"]?.integerValue {
|
|
items.append(field("Transfer speed", AgentToolDetailFormatting.byteCount(value) + "/s"))
|
|
}
|
|
if let path = object["failed_path"]?.stringValue { items.append(field("Failed path", path)) }
|
|
if let error = object["error"]?.stringValue { items.append(.message(error, .failure)) }
|
|
return items
|
|
}
|
|
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
AgentToolDetailFormatting.objectItems(input) { fields($0, locale: input.locale) }
|
|
}
|
|
|
|
public static func presentArguments(_ input: AgentToolArgumentDetailInput) -> [AgentToolDetail.Item] {
|
|
fields(input.arguments, locale: input.locale)
|
|
}
|
|
}
|