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
11 KiB
Swift
246 lines
11 KiB
Swift
import AgentKit
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
// The two scratch tools that touch a host.
|
|
//
|
|
// They live here rather than with the rest of the scratch family because they
|
|
// are the only two that leave the workspace: `scratch_pull` opens an SFTP
|
|
// client to read a remote file into it, and `scratch_push` writes one back.
|
|
// Everything between those two ends — read, search, edit, diff — is the
|
|
// runtime's and knows nothing about SSH.
|
|
//
|
|
// That is also the shape the pair exists for. A remote file can be pulled,
|
|
// edited and pushed back without a copy of it crossing the model boundary in
|
|
// either direction, and the only step the user is asked to approve is the one
|
|
// that reaches their host.
|
|
|
|
nonisolated public struct ScratchPullTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(id: "builtin.scratch_pull", present: present)
|
|
public let workspace: AgentScratchWorkspace
|
|
public let pool: AgentConnectionPool
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.scratchPull,
|
|
"Copy a remote file into the scratch workspace without returning its contents.",
|
|
properties: [
|
|
"host_id": Self.string(), "remote_path": Self.string(max: 4_096),
|
|
"scratch_path": Self.string(max: AgentScratchWorkspace.Limits.pathBytes),
|
|
], required: ["host_id", "remote_path", "scratch_path"], target: .host,
|
|
approvalPolicy: .approve, concurrency: .parallel,
|
|
presentation: .init(
|
|
symbol: "arrow.down.doc",
|
|
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
|
|
output: .json, actionKind: .pull
|
|
)
|
|
)
|
|
}
|
|
|
|
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("remote_path"))
|
|
_ = try AgentScratchWorkspace.normalize(arguments.string("scratch_path"))
|
|
return AgentToolPreflight(invocation: checked, approvalPolicy: .approve)
|
|
}
|
|
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
let arguments = try Arguments(invocation)
|
|
let remote = try AgentToolSupport.normalizeRemotePath(arguments.string("remote_path"))
|
|
let hostID = try arguments.uuid("host_id")
|
|
let data = try await AgentToolSupport.withAuthenticatedSFTP(
|
|
hostID: hostID, invocation: invocation, context: context,
|
|
authentication: authentication, pool: pool,
|
|
schedulesDataTransfer: true
|
|
) { client in
|
|
try await client.readFile(at: remote, maximumBytes: .max)
|
|
}
|
|
let written = try await workspace.write(arguments.string("scratch_path"), data: data)
|
|
let text = String(data: data, encoding: .utf8).map(LineEnding.normalizedToLF)
|
|
return Self.result(
|
|
invocation,
|
|
.object([
|
|
"remote_path": .string(remote), "scratch_path": .string(written.path),
|
|
"bytes": .number(Double(written.bytes)), "sha256": .string(written.sha256),
|
|
"is_utf8": .bool(text != nil),
|
|
"lines": text.map { .number(Double(UnifiedDiff.split($0).lines.count)) } ?? .null,
|
|
]))
|
|
}
|
|
|
|
public init(
|
|
workspace: AgentScratchWorkspace, pool: AgentConnectionPool, authentication: AgentSSHAuthenticationRegistry
|
|
) {
|
|
self.workspace = workspace
|
|
self.pool = pool
|
|
self.authentication = authentication
|
|
}
|
|
}
|
|
|
|
nonisolated public struct ScratchPushTool: AgentToolDefinition, AgentToolSchemaBuilding {
|
|
public static let presenter = AgentToolDetailPresenter(
|
|
id: "builtin.scratch_push", present: present,
|
|
presentArguments: presentArguments
|
|
)
|
|
public let workspace: AgentScratchWorkspace
|
|
public let pool: AgentConnectionPool
|
|
public let authentication: AgentSSHAuthenticationRegistry
|
|
|
|
public var descriptor: AgentToolDescriptor {
|
|
Self.descriptor(
|
|
TermindToolName.scratchPush, "Copy a scratch file atomically to a remote path.",
|
|
properties: [
|
|
"host_id": Self.string(),
|
|
"scratch_path": Self.string(max: AgentScratchWorkspace.Limits.pathBytes),
|
|
"remote_path": Self.string(max: 4_096), "overwrite": Self.boolean(),
|
|
], required: ["host_id", "scratch_path", "remote_path"], target: .host,
|
|
approvalPolicy: .ask,
|
|
presentation: .init(
|
|
symbol: "arrow.up.doc",
|
|
activity: .semanticArgument(key: "remote_path", fallback: .remoteFile),
|
|
output: .json, actionKind: .push
|
|
)
|
|
)
|
|
}
|
|
|
|
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("remote_path"))
|
|
let staged = try arguments.string("scratch_path")
|
|
let file = try await workspace.data(at: staged)
|
|
return AgentToolPreflight(
|
|
invocation: checked, approvalPolicy: .ask,
|
|
reasons: Self.evidence(path: staged, file: file),
|
|
executionMetadata: ["scratch_sha256": .string(file.sha256)]
|
|
)
|
|
}
|
|
|
|
public func execute(_ invocation: AgentToolInvocation, context: AgentToolExecutionContext) async throws
|
|
-> AgentToolResult
|
|
{
|
|
let arguments = try Arguments(invocation)
|
|
let remote = try AgentToolSupport.normalizeRemotePath(arguments.string("remote_path"))
|
|
let staged = try arguments.string("scratch_path")
|
|
let file = try await workspace.data(at: staged)
|
|
if let approved = context.preflightMetadata["scratch_sha256"]?.stringValue,
|
|
approved != file.sha256
|
|
{
|
|
throw AgentToolError.invalidArguments(
|
|
"\(staged) changed after this push was approved. Nothing was written."
|
|
)
|
|
}
|
|
let hostID = try arguments.uuid("host_id")
|
|
try await AgentToolSupport.withAuthenticatedSFTP(
|
|
hostID: hostID, invocation: invocation, context: context,
|
|
authentication: authentication, pool: pool,
|
|
byteCount: Int64(file.bytes), schedulesDataTransfer: true
|
|
) { client in
|
|
try await client.writeFile(
|
|
file.data, at: remote, overwrite: arguments.optionalBool("overwrite") ?? false
|
|
)
|
|
}
|
|
return Self.result(
|
|
invocation,
|
|
.object([
|
|
"scratch_path": .string(staged), "remote_path": .string(remote),
|
|
"bytes": .number(Double(file.bytes)), "sha256": .string(file.sha256),
|
|
"written": .bool(true),
|
|
]))
|
|
}
|
|
|
|
private static func evidence(path: String, file: ScratchFile) -> [String] {
|
|
var reasons = ["The content comes from the scratch workspace: \(path)"]
|
|
guard let text = String(data: file.data, encoding: .utf8) else {
|
|
reasons.append("\(file.bytes) bytes of non-text data, sha256 \(file.sha256.prefix(16))…")
|
|
return reasons
|
|
}
|
|
let lines = UnifiedDiff.split(LineEnding.normalizedToLF(text)).lines
|
|
reasons.append("\(file.bytes) bytes, \(lines.count) lines, sha256 \(file.sha256.prefix(16))…")
|
|
reasons.append(
|
|
"First lines: "
|
|
+ AgentSensitiveDataRedactor.visibleText(
|
|
lines.prefix(20).joined(separator: "\n"), maximumCharacters: 600
|
|
))
|
|
return reasons
|
|
}
|
|
|
|
public init(
|
|
workspace: AgentScratchWorkspace, pool: AgentConnectionPool, authentication: AgentSSHAuthenticationRegistry
|
|
) {
|
|
self.workspace = workspace
|
|
self.pool = pool
|
|
self.authentication = authentication
|
|
}
|
|
}
|
|
|
|
nonisolated extension ScratchPullTool {
|
|
public static func present(
|
|
_ input: AgentToolDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Remote path", object["remote_path"]?.stringValue, locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Scratch path", object["scratch_path"]?.stringValue, locale: input.locale, monospaced: true)
|
|
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
|
items.append(
|
|
F.field(
|
|
"Content type",
|
|
object["is_utf8"]?.boolValue == true
|
|
? F.localized("UTF-8 text", locale: input.locale)
|
|
: F.localized("Binary", locale: input.locale),
|
|
locale: input.locale
|
|
))
|
|
if let lines = object["lines"]?.integerValue {
|
|
items.append(F.field("Lines", String(lines), locale: input.locale))
|
|
}
|
|
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
|
return items
|
|
}
|
|
}
|
|
}
|
|
|
|
nonisolated extension ScratchPushTool {
|
|
public static func present(
|
|
_ input: AgentToolDetailInput
|
|
) -> [AgentToolDetail.Item] {
|
|
typealias F = AgentToolDetailFormatting
|
|
return F.objectItems(input) { object in
|
|
var items: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Scratch path", object["scratch_path"]?.stringValue, locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Remote path", object["remote_path"]?.stringValue, locale: input.locale, monospaced: true)
|
|
F.appendBytes(&items, "Size", object["bytes"], locale: input.locale)
|
|
F.appendField(&items, "SHA-256", object["sha256"]?.stringValue, locale: input.locale, monospaced: true)
|
|
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: [AgentToolDetail.Item] = []
|
|
F.appendField(
|
|
&items, "Scratch path", input.arguments["scratch_path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
F.appendField(
|
|
&items, "Remote path", input.arguments["remote_path"]?.stringValue,
|
|
locale: input.locale, monospaced: true)
|
|
SFTPToolPresentation.appendBoolean(
|
|
&items, label: "Overwrite", value: input.arguments["overwrite"],
|
|
locale: input.locale)
|
|
return items
|
|
}
|
|
}
|