692 lines
29 KiB
Swift
692 lines
29 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
nonisolated public protocol SFTPToolDefinition: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
static var name: String { get }
|
|
static var summary: String { get }
|
|
static var approvalPolicy: AgentToolDescriptor.ApprovalPolicy { get }
|
|
static var concurrency: AgentToolDescriptor.Concurrency { get }
|
|
static var extraProperties: [String: AgentJSONValue] { get }
|
|
static var extraRequired: [String] { get }
|
|
static var presentation: AgentToolDescriptor.Presentation { get }
|
|
static var schedulesDataTransfer: Bool { get }
|
|
var authentication: AgentSSHAuthenticationRegistry { get }
|
|
var connections: AgentConnectionPool { get }
|
|
func estimatedByteCount(arguments: Arguments) throws -> Int64?
|
|
func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult
|
|
}
|
|
|
|
nonisolated extension SFTPToolDefinition {
|
|
public static var concurrency: AgentToolDescriptor.Concurrency {
|
|
approvalPolicy == .approve ? .parallel : .sequential
|
|
}
|
|
public static var extraProperties: [String: AgentJSONValue] { [:] }
|
|
public static var extraRequired: [String] { [] }
|
|
public static var schedulesDataTransfer: Bool { false }
|
|
|
|
public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { nil }
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
var properties: [String: AgentJSONValue] = [
|
|
"host_id": Self.string(), "path": Self.string(max: 4_096),
|
|
]
|
|
properties.merge(Self.extraProperties) { _, new in new }
|
|
return Self.descriptor(
|
|
Self.name, Self.summary, properties: properties,
|
|
required: ["host_id", "path"] + Self.extraRequired,
|
|
target: .host, approvalPolicy: Self.approvalPolicy, concurrency: Self.concurrency,
|
|
presentation: Self.presentation
|
|
)
|
|
}
|
|
|
|
public func preflight(_ invocation: AgentToolInvocation) async throws -> AgentToolPreflight {
|
|
var checked = invocation
|
|
let arguments = try Arguments(invocation)
|
|
checked.targetHostID = try arguments.uuid("host_id")
|
|
_ = try AgentToolSupport.normalizeRemotePath(arguments.string("path"))
|
|
return AgentToolPreflight(
|
|
invocation: checked,
|
|
approvalPolicy: Self.approvalPolicy
|
|
)
|
|
}
|
|
|
|
public func execute(
|
|
_ invocation: AgentToolInvocation, context: AgentToolExecutionContext
|
|
) async throws -> AgentToolResult {
|
|
let arguments = try Arguments(invocation)
|
|
let hostID = try arguments.uuid("host_id")
|
|
let path = try AgentToolSupport.normalizeRemotePath(arguments.string("path"))
|
|
return try await AgentToolSupport.withAuthenticatedSFTP(
|
|
hostID: hostID, invocation: invocation, context: context,
|
|
authentication: authentication, pool: connections,
|
|
byteCount: try estimatedByteCount(arguments: arguments),
|
|
schedulesDataTransfer: Self.schedulesDataTransfer
|
|
) { client in
|
|
try await perform(
|
|
client: client, path: path, arguments: arguments, invocation: invocation
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPListTool: SFTPToolDefinition {
|
|
public static let name = "sftp_list"
|
|
public static let summary = "List a remote directory."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_list", present: present
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "folder", activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
|
|
output: .automatic, actionKind: .list
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
let files = try await client.listDirectory(at: path)
|
|
return Self.result(invocation, .array(files.map(AgentToolSupport.remoteFileJSON)))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPStatTool: SFTPToolDefinition {
|
|
public static let name = "sftp_stat"
|
|
public static let summary = "Read remote file metadata."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_stat", present: present
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "info.circle", activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .inspect
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
guard let file = try await client.attributes(at: path) else { throw AgentToolError.notFound }
|
|
return Self.result(invocation, AgentToolSupport.remoteFileJSON(file))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPReadTool: SFTPToolDefinition {
|
|
public static let name = "sftp_read"
|
|
public static let summary =
|
|
"Read a UTF-8 remote file by lines (offset starts at 1). Pages contain at most 2,000 lines or 64 KiB. Continue at next_offset."
|
|
public static let extraProperties = [
|
|
"offset": integer(min: 1, max: 10_000_000), "limit": integer(min: 1, max: 2_000),
|
|
]
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .approve
|
|
public static let schedulesDataTransfer = true
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_read", layout: .compact, present: present
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "doc.text", activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .read
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func estimatedByteCount(arguments _: Arguments) throws -> Int64? { Int64(AgentTruncateLimits.maximumBytes) }
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
let page = try await client.readTextPage(
|
|
at: path, offset: arguments.optionalInt("offset") ?? 1,
|
|
limit: arguments.optionalInt("limit") ?? AgentTextPageReader.defaultLineLimit
|
|
)
|
|
var result = Self.result(
|
|
invocation,
|
|
.object([
|
|
"path": .string(path), "content": .string(page.content),
|
|
"bytes": .number(Double(page.content.utf8.count)),
|
|
"offset": .number(Double(page.offset)), "returned_lines": .number(Double(page.returnedLines)),
|
|
"next_offset": page.nextOffset.map { .number(Double($0)) } ?? .null,
|
|
"eof": .bool(!page.isTruncated),
|
|
]))
|
|
result.hasBoundedModelContent = true
|
|
return result
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPWriteTool: SFTPToolDefinition {
|
|
public static let name = "sftp_write"
|
|
public static let summary = "Write a bounded UTF-8 remote file atomically."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
|
|
public static let schedulesDataTransfer = true
|
|
public static let extraProperties = ["content": string(max: 131_072), "overwrite": boolean()]
|
|
public static let extraRequired = ["content"]
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_write", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "square.and.pencil",
|
|
activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .write
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func estimatedByteCount(arguments: Arguments) throws -> Int64? {
|
|
Int64(try arguments.string("content").utf8.count)
|
|
}
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
try await client.writeFile(
|
|
Data(try arguments.string("content").utf8), at: path,
|
|
overwrite: arguments.optionalBool("overwrite") ?? false)
|
|
return Self.result(invocation, .object(["path": .string(path), "written": .bool(true)]))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPMkdirTool: SFTPToolDefinition {
|
|
public static let name = "sftp_mkdir"
|
|
public static let summary = "Create a remote directory."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_mkdir", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "folder.badge.plus",
|
|
activity: .semanticArgument(key: "path", fallback: .remoteDirectory),
|
|
output: .automatic, actionKind: .create
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
try await client.createDirectory(at: path)
|
|
return Self.result(invocation, .object(["path": .string(path), "created": .bool(true)]))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPMoveTool: SFTPToolDefinition {
|
|
public static let name = "sftp_move"
|
|
public static let summary = "Move or rename a remote path."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
|
|
public static let extraProperties = ["destination": string(max: 4_096), "overwrite": boolean()]
|
|
public static let extraRequired = ["destination"]
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_move", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "arrow.right", activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .move
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
let destination = try AgentToolSupport.normalizeRemotePath(arguments.string("destination"))
|
|
try await client.rename(
|
|
from: path, to: destination,
|
|
overwrite: arguments.optionalBool("overwrite") ?? false)
|
|
return Self.result(
|
|
invocation,
|
|
.object([
|
|
"source": .string(path), "destination": .string(destination), "moved": .bool(true),
|
|
]))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPChmodTool: SFTPToolDefinition {
|
|
public static let name = "sftp_chmod"
|
|
public static let summary = "Change remote POSIX permissions."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
|
|
public static let extraProperties = ["mode": string(max: 4)]
|
|
public static let extraRequired = ["mode"]
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_chmod", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "lock", activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .changePermissions
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
let raw = try arguments.string("mode")
|
|
guard (3...4).contains(raw.count), raw.allSatisfy({ ("0"..."7").contains(String($0)) }),
|
|
let mode = UInt32(raw, radix: 8)
|
|
else {
|
|
throw AgentToolError.invalidArguments("mode must be a 3- or 4-digit octal value.")
|
|
}
|
|
try await client.setPermissions(at: path, to: mode)
|
|
return Self.result(invocation, .object(["path": .string(path), "mode": .string(raw)]))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated public struct SFTPDeleteTool: SFTPToolDefinition {
|
|
public static let name = "sftp_delete"
|
|
public static let summary = "Permanently delete one remote file or empty directory."
|
|
public static let approvalPolicy: AgentToolDescriptor.ApprovalPolicy = .ask
|
|
public static let extraProperties = ["kind": enumeration(["file", "directory"])]
|
|
public static let extraRequired = ["kind"]
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.sftp_delete", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public static let presentation = AgentToolDescriptor.Presentation(
|
|
symbol: "trash", activity: .semanticArgument(key: "path", fallback: .remoteFile),
|
|
output: .automatic, actionKind: .delete
|
|
)
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
public let connections: AgentConnectionPool
|
|
public func perform(
|
|
client: any SFTPChannelProtocol, path: String, arguments: Arguments,
|
|
invocation: AgentToolInvocation
|
|
) async throws -> AgentToolResult {
|
|
if try arguments.string("kind") == "directory" {
|
|
try await client.removeDirectory(at: path)
|
|
} else {
|
|
try await client.removeFile(at: path)
|
|
}
|
|
return Self.result(invocation, .object(["path": .string(path), "deleted": .bool(true)]))
|
|
}
|
|
|
|
public init(authentication: AgentSSHAuthenticationRegistry, connections: AgentConnectionPool) {
|
|
self.authentication = authentication
|
|
self.connections = connections
|
|
}
|
|
}
|
|
|
|
nonisolated extension SFTPListTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.arrayItems(input) { values in
|
|
let rows = values.compactMap {
|
|
SFTPToolPresentation.remoteFileRow($0, locale: input.locale)
|
|
}
|
|
return [
|
|
.message(String(localized: "\(rows.count) items", locale: input.locale), .secondary),
|
|
.list(
|
|
.init(
|
|
title: nil, rows: rows,
|
|
emptyMessage: F.localized("Directory is empty", locale: input.locale)
|
|
)),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
nonisolated extension SFTPStatTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Kind",
|
|
object["kind"]?.stringValue.map {
|
|
F.kindLabel($0, locale: input.locale)
|
|
}, locale: input.locale)
|
|
if let bytes = object["size"]?.integerValue {
|
|
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
|
}
|
|
F.appendField(
|
|
&items, "Permissions", object["permissions"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Modified", object["modified_at"]?.stringValue.map(F.displayDate),
|
|
locale: input.locale)
|
|
return items
|
|
}
|
|
}
|
|
}
|
|
nonisolated extension SFTPReadTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
let content = object["content"]?.stringValue ?? ""
|
|
items.append(
|
|
content.isEmpty
|
|
? .message(F.localized("Empty file", locale: input.locale), .secondary)
|
|
: .text(
|
|
.init(
|
|
title: F.localized("Content", locale: input.locale),
|
|
text: content, style: .monospaced)))
|
|
if let bytes = object["bytes"]?.integerValue {
|
|
items.append(F.field("Size", F.byteCount(bytes), locale: input.locale))
|
|
}
|
|
return items
|
|
}
|
|
}
|
|
}
|
|
nonisolated extension SFTPWriteTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
if let content = input.arguments["content"]?.stringValue {
|
|
items.append(F.field("Size", F.byteCount(content.utf8.count), locale: input.locale))
|
|
}
|
|
items.append(
|
|
F.field(
|
|
"Result", F.localized("Written", locale: input.locale),
|
|
locale: input.locale))
|
|
return items
|
|
}
|
|
}
|
|
|
|
public static func presentArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items = SFTPToolPresentation.pathArguments(input)
|
|
if let content = input.arguments["content"]?.stringValue {
|
|
items.append(F.text("Content", content, locale: input.locale))
|
|
}
|
|
SFTPToolPresentation.appendBoolean(
|
|
&items, label: "Overwrite", value: input.arguments["overwrite"],
|
|
locale: input.locale)
|
|
return items
|
|
}
|
|
}
|
|
nonisolated extension SFTPMkdirTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
SFTPToolPresentation.pathDetail(
|
|
input,
|
|
result: AgentToolDetailFormatting.localized(
|
|
"Directory created", locale: input.locale
|
|
)
|
|
)
|
|
}
|
|
|
|
public static func presentArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
SFTPToolPresentation.pathArguments(input)
|
|
}
|
|
}
|
|
nonisolated extension SFTPMoveTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Source", object["source"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Destination", object["destination"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
return items
|
|
}
|
|
}
|
|
|
|
public static func presentArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Source", input.arguments["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Destination", input.arguments["destination"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
SFTPToolPresentation.appendBoolean(
|
|
&items, label: "Overwrite", value: input.arguments["overwrite"],
|
|
locale: input.locale)
|
|
return items
|
|
}
|
|
}
|
|
nonisolated extension SFTPChmodTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Permissions", object["mode"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
return items
|
|
}
|
|
}
|
|
|
|
public static func presentArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items = SFTPToolPresentation.pathArguments(input)
|
|
F.appendField(
|
|
&items, "Permissions", input.arguments["mode"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
return items
|
|
}
|
|
}
|
|
nonisolated extension SFTPDeleteTool {
|
|
public static func present(_ input: AgentToolDetailInput) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Kind",
|
|
input.arguments["kind"]?.stringValue.map {
|
|
F.kindLabel($0, locale: input.locale)
|
|
}, locale: input.locale)
|
|
items.append(
|
|
F.field(
|
|
"Result", F.localized("Deleted", locale: input.locale),
|
|
locale: input.locale))
|
|
return items
|
|
}
|
|
}
|
|
|
|
public static func presentArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items = SFTPToolPresentation.pathArguments(input)
|
|
F.appendField(
|
|
&items, "Kind",
|
|
input.arguments["kind"]?.stringValue.map {
|
|
F.kindLabel($0, locale: input.locale)
|
|
}, locale: input.locale)
|
|
return items
|
|
}
|
|
}
|
|
|
|
nonisolated public enum SFTPToolPresentation {
|
|
public static func pathArguments(
|
|
_ input: AgentToolArgumentDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", input.arguments["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
return items
|
|
}
|
|
|
|
public static func appendBoolean(
|
|
_ items: inout [AgentToolDetail.Item],
|
|
label: String.LocalizationValue,
|
|
value: AgentJSONValue?,
|
|
locale: Locale
|
|
) {
|
|
guard let value = value?.boolValue else { return }
|
|
items.append(
|
|
AgentToolDetailFormatting.field(
|
|
label,
|
|
AgentToolDetailFormatting.localized(value ? "Yes" : "No", locale: locale),
|
|
locale: locale
|
|
))
|
|
}
|
|
|
|
public static func remoteFileRow(
|
|
_ value: AgentJSONValue, locale: Locale
|
|
) -> AgentToolDetail.ListRow? {
|
|
typealias F = AgentToolDetailFormatting
|
|
guard let object = value.objectValue else { return nil }
|
|
let path = object["path"]?.stringValue ?? ""
|
|
let name =
|
|
F.nonempty(object["name"]?.stringValue)
|
|
?? F.nonempty(path.split(separator: "/").last.map(String.init))
|
|
?? F.localized("Unnamed item", locale: locale)
|
|
let kind = object["kind"]?.stringValue ?? ""
|
|
var metadata = [F.kindLabel(kind, locale: locale)]
|
|
if let bytes = object["size"]?.integerValue { metadata.append(F.byteCount(bytes)) }
|
|
if let permissions = F.nonempty(object["permissions"]?.stringValue) {
|
|
metadata.append(permissions)
|
|
}
|
|
return .init(
|
|
title: name, subtitle: metadata.filter { !$0.isEmpty }.joined(separator: " · "),
|
|
detail: object["modified_at"]?.stringValue.map(F.displayDate),
|
|
symbol: kind == "directory" ? "folder" : "doc"
|
|
)
|
|
}
|
|
|
|
public static func pathDetail(
|
|
_ input: AgentToolDetailInput, result: String
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Path", object["path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
items.append(F.field("Result", result, locale: input.locale))
|
|
return items
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated extension AgentToolSupport {
|
|
|
|
/// One authenticated SFTP operation. Connection setup may safely retry
|
|
/// after a rejected credential because the operation has not begun. Once
|
|
/// the client is handed to `operation`, a lost connection is only evicted:
|
|
/// replaying a move, delete or write would have unknowable side effects.
|
|
public static func withAuthenticatedSFTP<Value: Sendable>(
|
|
hostID: Host.ID, invocation: AgentToolInvocation,
|
|
context: AgentToolExecutionContext,
|
|
authentication: AgentSSHAuthenticationRegistry, pool: AgentConnectionPool,
|
|
byteCount: Int64? = nil,
|
|
schedulesDataTransfer: Bool = false,
|
|
operation: @escaping @Sendable (any SFTPChannelProtocol) async throws -> Value
|
|
) async throws -> Value {
|
|
try await authentication.withSFTP(
|
|
hostID: hostID, invocationID: invocation.id,
|
|
interaction: context.sshInteraction, pool: pool,
|
|
runID: context.runID, byteCount: byteCount,
|
|
schedulesDataTransfer: schedulesDataTransfer
|
|
) { client in
|
|
do {
|
|
return try await operation(client)
|
|
} catch let error as SFTPError {
|
|
switch error {
|
|
case .notConnected, .connectionLost:
|
|
await pool.disconnect(hostID: hostID)
|
|
case .cancelled, .localFile, .operation:
|
|
break
|
|
}
|
|
throw error
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Internal rather than private because `scratch_pull` and `scratch_push` aim at
|
|
/// the same remote paths the SFTP tools do, and two implementations of "what is
|
|
/// a valid remote path" would be one too many.
|
|
public static func normalizeRemotePath(_ raw: String) throws -> String {
|
|
guard raw.hasPrefix("/"), !raw.contains("\0"), !raw.contains("\r"), !raw.contains("\n") else {
|
|
throw AgentToolError.invalidPath
|
|
}
|
|
var components: [Substring] = []
|
|
for component in raw.split(separator: "/") {
|
|
if component == "." { continue }
|
|
if component == ".." {
|
|
guard !components.isEmpty else { throw AgentToolError.invalidPath }
|
|
components.removeLast()
|
|
} else {
|
|
components.append(component)
|
|
}
|
|
}
|
|
return "/" + components.joined(separator: "/")
|
|
}
|
|
|
|
fileprivate static func remoteFileJSON(_ file: RemoteFile) -> AgentJSONValue {
|
|
.object([
|
|
"path": .string(file.path), "name": .string(file.name),
|
|
"kind": .string(file.kind.rawValue), "size": .number(Double(file.size)),
|
|
"permissions": .string(file.octalPermissionDescription),
|
|
"modified_at": file.modifiedAt.map { .string($0.ISO8601Format()) } ?? .null,
|
|
])
|
|
}
|
|
}
|