Files
Termind/TFMTests/AgentScratchTests.swift
T

1181 lines
49 KiB
Swift

import AgentKit
import AgentKitScrubber
import Foundation
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import TermindPresentation
import TermindRemoteInfrastructure
import Testing
@testable import Termind
@Suite
struct AgentScratchTests {
// MARK: - Containment
@Test
func aPathThatCouldLeaveTheWorkspaceIsRefused() async throws {
let workspace = Fixture.workspace()
for path in [
"../escape", "a/../../b", "/etc/passwd", "~/secrets", "",
"a/\u{0}b", "line\nbreak", String(repeating: "x", count: 300),
(1...9).map(String.init).joined(separator: "/"),
] {
await #expect(throws: (any Error).self, "\(path) was allowed") {
try await workspace.write(path, data: Data("x".utf8))
}
}
// `.` and redundant separators are normalized away rather than refused —
// they name a path inside the workspace perfectly well.
_ = try await workspace.write("./a//b.txt", data: Data("ok".utf8))
#expect(try await workspace.read("a/b.txt").content == "ok")
}
/// Containment alone would already stop an escape. The stricter rule is what
/// keeps an alias *inside* the workspace from making two paths the same file,
/// which would quietly break both the quota arithmetic and the atomicity
/// `apply(_:to:)` claims.
@Test
func aSymlinkPlantedByHandIsRefusedRatherThanFollowed() async throws {
let base = Fixture.base()
let id = UUID()
let workspace = AgentScratchWorkspace(conversationID: id, base: base)
_ = try await workspace.write("anchor.txt", data: Data("anchor".utf8))
let outside = base.appending(path: "outside.txt")
try Data("outside".utf8).write(to: outside)
let scope = base.appending(path: id.uuidString)
try FileManager.default.createSymbolicLink(
at: scope.appending(path: "link.txt"), withDestinationURL: outside
)
try FileManager.default.createSymbolicLink(
at: scope.appending(path: "inside.txt"),
withDestinationURL: scope.appending(path: "anchor.txt")
)
await #expect(throws: (any Error).self) { try await workspace.read("link.txt") }
await #expect(throws: (any Error).self) { try await workspace.read("inside.txt") }
// And it is not reported as content either, so nothing can act on it.
let listed = try await workspace.list(nil, recursive: true).map(\.path)
#expect(listed == ["anchor.txt"])
let searched = try await workspace.search("anchor")
#expect(searched.matches.map(\.path) == ["anchor.txt"])
#expect(searched.searchedFiles == 1)
}
@Test
func twoConversationsCannotSeeEachOther() async throws {
let base = Fixture.base()
let first = AgentScratchWorkspace(conversationID: UUID(), base: base)
let second = AgentScratchWorkspace(conversationID: UUID(), base: base)
_ = try await first.write("notes.md", data: Data("first".utf8))
#expect(try await second.list(nil, recursive: true).isEmpty)
await #expect(throws: (any Error).self) { try await second.read("notes.md") }
// And deleting one scope leaves the other alone.
_ = try await second.write("notes.md", data: Data("second".utf8))
try await first.removeScope()
#expect(try await second.read("notes.md").content == "second")
}
// MARK: - Quota
@Test
func aWritePastTheEntryLimitFailsAndChangesNothing() async throws {
let workspace = Fixture.workspace()
for index in 0..<AgentScratchWorkspace.Limits.entries {
_ = try await workspace.write("f\(index).txt", data: Data("x".utf8))
}
let before = try await workspace.usage()
await #expect(throws: (any Error).self) {
try await workspace.write("one-too-many.txt", data: Data("x".utf8))
}
#expect(try await workspace.usage() == before)
#expect(before.entries == AgentScratchWorkspace.Limits.entries)
// Overwriting an existing file adds no entry, so it is still allowed at the
// ceiling — otherwise a full workspace could not even be corrected.
_ = try await workspace.write("f0.txt", data: Data("replaced".utf8))
#expect(try await workspace.read("f0.txt").content == "replaced")
}
@Test
func aFilePastTheSizeLimitIsRefusedBeforeAnythingIsWritten() async throws {
let workspace = Fixture.workspace()
let tooBig = Data(repeating: 0x61, count: AgentScratchWorkspace.Limits.fileBytes + 1)
await #expect(throws: (any Error).self) {
try await workspace.write("big.bin", data: tooBig)
}
#expect(try await workspace.usage().bytes == 0)
#expect(try await workspace.list(nil, recursive: true).isEmpty)
}
/// A quota an agent could evade by naming a file `.big` is not a quota.
@Test
func hiddenFilesCountAgainstTheQuotaAndAppearInListings() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write(".hidden", data: Data(repeating: 0x61, count: 100))
let usage = try await workspace.usage()
#expect(usage.bytes == 100)
#expect(usage.entries == 1)
#expect(try await workspace.list(nil, recursive: true).map(\.path) == [".hidden"])
}
// MARK: - Reading
@Test
func readReturnsABoundedWindowAndSaysWhenMoreRemains() async throws {
let workspace = Fixture.workspace()
let body = (1...100).map { "line \($0)" }.joined(separator: "\n") + "\n"
_ = try await workspace.write("log.txt", data: Data(body.utf8))
let window = try await workspace.read("log.txt", offset: 10, limit: 3)
#expect(window.content == "line 10\nline 11\nline 12")
#expect(window.offset == 10)
#expect(window.returnedLines == 3)
#expect(window.totalLines == 100)
#expect(window.isTruncated)
let tail = try await workspace.read("log.txt", offset: 98)
#expect(tail.returnedLines == 3)
#expect(!tail.isTruncated)
await #expect(throws: (any Error).self) {
try await workspace.read("log.txt", offset: 500)
}
}
@Test
func aNonTextFileIsRefusedByReadButStillMovable() async throws {
let workspace = Fixture.workspace()
let binary = Data([0xFF, 0xFE, 0x00, 0x01, 0x80])
_ = try await workspace.write("blob.bin", data: binary)
await #expect(throws: (any Error).self) { try await workspace.read("blob.bin") }
// `data(at:)` is what scratch_push uses, and it does not decode.
#expect(try await workspace.data(at: "blob.bin").data == binary)
}
// MARK: - Searching
@Test
func searchSupportsLiteralRegexAndSmartCaseMatching() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write(
"a.txt", data: Data("Alpha alpha\nneedle twice needle\nserver_42\n".utf8)
)
_ = try await workspace.write(
"b.txt", data: Data("alpha\nserver-x\n".utf8)
)
let insensitive = try await workspace.search("alpha")
#expect(!insensitive.caseSensitive)
#expect(insensitive.matchingLines == 2)
#expect(insensitive.matches.map { "\($0.path):\($0.line)" } == ["a.txt:1", "b.txt:1"])
let smartCase = try await workspace.search("Alpha")
#expect(smartCase.caseSensitive)
#expect(smartCase.matches.map(\.path) == ["a.txt"])
let overridden = try await workspace.search("Alpha", caseSensitive: false)
#expect(!overridden.caseSensitive)
#expect(overridden.matchingLines == 2)
let regex = try await workspace.search(#"server_[0-9]+"#, regex: true)
#expect(regex.matches.map { "\($0.path):\($0.line)" } == ["a.txt:3"])
// Search results are matching lines, not occurrences within those lines.
let duplicate = try await workspace.search("needle")
#expect(duplicate.matchingLines == 1)
#expect(duplicate.matches.count == 1)
}
@Test
func searchScopesAndOrdersFilesAndSkipsBinaryContent() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("z.txt", data: Data("hit at root\n".utf8))
_ = try await workspace.write("dir/b.txt", data: Data("before\r\nhit b\r\n".utf8))
_ = try await workspace.write("dir/a.txt", data: Data("hit a\n".utf8))
_ = try await workspace.write(".hidden", data: Data("hidden hit\n".utf8))
_ = try await workspace.write("dir/blob.bin", data: Data([0xFF, 0x00, 0x80]))
let directory = try await workspace.search("hit", path: "dir")
#expect(
directory.matches.map { "\($0.path):\($0.line)" } == [
"dir/a.txt:1", "dir/b.txt:2",
])
#expect(directory.searchedFiles == 2)
#expect(directory.skippedBinaryFiles == 1)
let file = try await workspace.search("hit", path: "z.txt")
#expect(file.matches.map(\.path) == ["z.txt"])
#expect(file.searchedFiles == 1)
let wholeWorkspace = try await workspace.search("hit")
#expect(
wholeWorkspace.matches.map(\.path) == [
".hidden", "dir/a.txt", "dir/b.txt", "z.txt",
])
#expect(wholeWorkspace.searchedFiles == 4)
#expect(wholeWorkspace.skippedBinaryFiles == 1)
}
@Test
func searchBoundsResultsAndKeepsTheMatchVisibleInLongLines() async throws {
let workspace = Fixture.workspace()
let longLine =
String(repeating: "x", count: 5_000)
+ "target" + String(repeating: "y", count: 5_000)
_ = try await workspace.write(
"long.txt", data: Data((longLine + "\ntarget two\ntarget three\n").utf8)
)
let outcome = try await workspace.search("target", limit: 1)
#expect(outcome.matchingLines == 3)
#expect(outcome.matches.count == 1)
#expect(outcome.matches[0].text.contains("target"))
#expect(outcome.matches[0].textTruncated)
#expect(outcome.isTruncated)
}
@Test
func searchRejectsInvalidPatternsPathsAndBounds() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("a.txt", data: Data("text\n".utf8))
for query in ["", "two\nlines"] {
await #expect(throws: (any Error).self) {
try await workspace.search(query)
}
}
await #expect(throws: (any Error).self) {
try await workspace.search("[", regex: true)
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", path: "../outside")
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", path: "missing")
}
await #expect(throws: (any Error).self) {
try await workspace.search("text", limit: 101)
}
}
// MARK: - Editing
@Test
func everyEditMatchesTheOriginalRatherThanThePrecedingResult() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("c.conf", data: Data("alpha\nbeta\ngamma\n".utf8))
let outcome = try await workspace.replace(
[
ScratchReplacement(oldText: "alpha", newText: "one"),
ScratchReplacement(oldText: "gamma", newText: "three"),
],
in: "c.conf"
)
#expect(outcome.replacementsApplied == 2)
#expect(try await workspace.read("c.conf").content == "one\nbeta\nthree")
#expect(outcome.diff.firstChangedLine == 1)
#expect(outcome.diff.text.contains("-alpha"))
#expect(outcome.diff.text.contains("+three"))
// An old_text that exists only *after* the first edit lands is not matched:
// the model never has to simulate its own changes to write the second one.
_ = try await workspace.write("d.conf", data: Data("alpha\nbeta\n".utf8))
await #expect(throws: (any Error).self) {
try await workspace.replace(
[
ScratchReplacement(oldText: "alpha", newText: "one"),
ScratchReplacement(oldText: "one", newText: "uno"),
],
in: "d.conf"
)
}
#expect(try await workspace.read("d.conf").content == "alpha\nbeta")
}
@Test
func anAmbiguousOrOverlappingEditChangesNothingAtAll() async throws {
let workspace = Fixture.workspace()
let body = "listen 80;\nserver_name a;\nlisten 80;\n"
_ = try await workspace.write("n.conf", data: Data(body.utf8))
let before = try await workspace.data(at: "n.conf").sha256
await #expect(throws: (any Error).self) {
try await workspace.replace(
[ScratchReplacement(oldText: "listen 80;", newText: "listen 8080;")], in: "n.conf"
)
}
#expect(try await workspace.data(at: "n.conf").sha256 == before)
await #expect(throws: (any Error).self) {
try await workspace.replace(
[
ScratchReplacement(oldText: "server_name a;\nlisten", newText: "x"),
ScratchReplacement(oldText: "a;\nlisten 80;\n", newText: "y"),
],
in: "n.conf"
)
}
#expect(try await workspace.data(at: "n.conf").sha256 == before)
// Extending old_text until it names one region is the documented fix, and
// it works.
let outcome = try await workspace.replace(
[ScratchReplacement(oldText: "server_name a;\nlisten 80;", newText: "server_name a;\nlisten 8080;")],
in: "n.conf"
)
#expect(outcome.replacementsApplied == 1)
#expect(try await workspace.read("n.conf").content == "listen 80;\nserver_name a;\nlisten 8080;")
}
@Test
func aNoOpEditIsReportedRatherThanSilentlySucceeding() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("f.txt", data: Data("same\n".utf8))
await #expect(throws: (any Error).self) {
try await workspace.replace(
[ScratchReplacement(oldText: "same", newText: "same")], in: "f.txt"
)
}
}
/// Without the normalized retry, an edit fails whenever the model retypes a
/// block with tidied punctuation — which is often.
@Test
func punctuationAndTrailingWhitespaceDoNotDefeatAMatch() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write(
"q.txt", data: Data("say \u{201C}hello\u{201D} \u{2014} now \nkeep\n".utf8)
)
let outcome = try await workspace.replace(
[ScratchReplacement(oldText: "say \"hello\" - now", newText: "say goodbye")], in: "q.txt"
)
#expect(outcome.replacementsApplied == 1)
#expect(try await workspace.read("q.txt").content == "say goodbye\nkeep")
// A difference in an actual word is still a miss. The fallback forgives how
// text was typed, not what it says.
await #expect(throws: (any Error).self) {
try await workspace.replace(
[ScratchReplacement(oldText: "say farewell", newText: "x")], in: "q.txt"
)
}
}
@Test
func aWindowsFileKeepsItsLineEndingsAndBOM() async throws {
let workspace = Fixture.workspace()
let original = "\u{FEFF}alpha\r\nbeta\r\ngamma\r\n"
_ = try await workspace.write("win.ini", data: Data(original.utf8))
let outcome = try await workspace.replace(
[ScratchReplacement(oldText: "beta", newText: "BETA")], in: "win.ini"
)
#expect(outcome.lineEnding == .crlf)
// Asserted on the bytes, not on a decoded String. Decoding is what hides a
// BOM — Foundation strips it — and reading the file back through
// `String(data:encoding:)` here would report a pass whether or not those
// three bytes survived.
let updated = try await workspace.data(at: "win.ini").data
#expect(updated.starts(with: [0xEF, 0xBB, 0xBF]))
#expect(updated == Data("\u{FEFF}alpha\r\nBETA\r\ngamma\r\n".utf8))
// Only the edited line differs; the diff is not "every line changed".
#expect(outcome.diff.firstChangedLine == 2)
#expect(outcome.diff.text.components(separatedBy: "\n-").count == 2)
}
@Test
func editReportsADiffAndNeverTheNewFile() async throws {
let workspace = Fixture.workspace()
let body = (1...400).map { "setting_\($0) = off" }.joined(separator: "\n") + "\n"
_ = try await workspace.write("big.conf", data: Data(body.utf8))
let outcome = try await workspace.replace(
[ScratchReplacement(oldText: "setting_200 = off", newText: "setting_200 = on")],
in: "big.conf"
)
#expect(outcome.diff.firstChangedLine == 200)
#expect(outcome.diff.text.contains("+setting_200 = on"))
// The whole point: what comes back is a fraction of what changed hands.
#expect(outcome.diff.text.utf8.count < body.utf8.count / 10)
#expect(!outcome.diff.text.contains("setting_1 = off"))
}
@Test
func moreEditsThanTheLimitAreRefused() async throws {
let workspace = Fixture.workspace()
let count = AgentScratchWorkspace.Limits.replacements + 1
let body = (1...count).map { "v\($0)" }.joined(separator: "\n") + "\n"
_ = try await workspace.write("many.txt", data: Data(body.utf8))
await #expect(throws: (any Error).self) {
try await workspace.replace(
(1...count).map { ScratchReplacement(oldText: "v\($0)", newText: "w\($0)") },
in: "many.txt"
)
}
}
// MARK: - Deleting
@Test
func deleteTakesOneFileOrOneEmptyDirectory() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("tree/leaf.txt", data: Data("x".utf8))
// A directory with anything in it is refused, so there is no un-gated way to
// destroy a subtree.
await #expect(throws: (any Error).self) {
try await workspace.delete("tree", kind: .directory)
}
// And the kind has to match what is actually there.
await #expect(throws: (any Error).self) {
try await workspace.delete("tree", kind: .file)
}
try await workspace.delete("tree/leaf.txt", kind: .file)
try await workspace.delete("tree", kind: .directory)
#expect(try await workspace.list(nil, recursive: true).isEmpty)
}
// MARK: - Concurrency
/// The actor is the mutation queue Pi builds explicitly, which is what lets every
/// scratch tool stay `.parallel` without a batch ever observing a torn file.
@Test
func concurrentWritesAndReadsNeverObserveATornFile() async throws {
let workspace = Fixture.workspace()
let short = String(repeating: "a", count: 4_096)
let long = String(repeating: "b", count: 65_536)
_ = try await workspace.write("race.txt", data: Data(short.utf8))
await withTaskGroup(of: Void.self) { group in
for index in 0..<40 {
group.addTask {
let body = index.isMultiple(of: 2) ? short : long
_ = try? await workspace.write("race.txt", data: Data(body.utf8))
}
group.addTask {
guard let file = try? await workspace.data(at: "race.txt"),
let text = String(data: file.data, encoding: .utf8)
else { return }
// Every observation is one of the two whole values, never a
// prefix of one or a splice of both.
#expect(text == short || text == long)
}
}
}
}
// MARK: - Approval policy
@Test
func approvedActionsSkipTheGateInEveryPermissionMode() async {
for mode in AgentPermissionMode.allCases {
let reviewer = CountingSecurityReviewer()
let broker = AgentApprovalBroker(
reviewer: reviewer,
manualApproval: { _ in .deny("a person was asked") }
)
let allowed = await broker.authorize(
Fixture.request(approvalPolicy: .approve), mode: mode
)
#expect(allowed == .allow, "\(mode) gated an approved action")
#expect(await reviewer.count == 0, "\(mode) sent an approved action to review")
}
}
@Test
func askedRemoteWritesReachTheSelectedGate() async {
let asked = CountingSecurityReviewer()
let manual = AgentApprovalBroker(
reviewer: asked, manualApproval: { _ in .deny("denied by the user") }
)
#expect(
await manual.authorize(
Fixture.request(approvalPolicy: .ask), mode: .askForApproval
) == .deny("denied by the user")
)
// Ask for approval asks the person, not the reviewer.
#expect(await asked.count == 0)
// Approve for me hands the same request to Security Review, which decides.
let reviewed = CountingSecurityReviewer()
let automatic = AgentApprovalBroker(
reviewer: reviewed, manualApproval: { _ in .deny("nobody should be asked") }
)
#expect(
await automatic.authorize(
Fixture.request(approvalPolicy: .ask), mode: .approveForMe
) == .allow
)
#expect(await reviewed.count == 1)
}
// MARK: - Registry
/// A card records the presenter ID it was written with, and older cards record
/// only a tool name. `scratch_edit` became `scratch_replace`, so both routes
/// still have to reach a projection — otherwise every transcript that predates
/// the rename falls back to generic key/value fields.
@Test
func aCardWrittenAsScratchEditStillFindsItsProjection() throws {
let byID = try #require(TermindAgentTools.presenter(id: "builtin.scratch_edit"))
let byLegacyName = try #require(
TermindAgentTools.legacyPresenter(forToolNamed: "scratch_edit")
)
#expect(byLegacyName.id == byID.id)
#expect(TermindAgentTools.presenter(id: "builtin.scratch_replace") != nil)
// And it draws the old payload, which has no `replacements_applied`.
let items = byID.present(
AgentToolDetailInput(
result: .object([
"path": .string("app.conf"), "edits_applied": .number(2),
"diff": .string("@@ -1 +1 @@\n-a\n+b"), "bytes": .number(12),
]),
arguments: ["path": .string("app.conf")], locale: Locale(identifier: "en")
))
let showsPath = items.contains { item in
guard case .field(let field) = item else { return false }
return field.value == "app.conf"
}
let showsDiff = items.contains { item in
guard case .text(let block) = item else { return false }
return block.text.contains("+b")
}
#expect(showsPath)
#expect(showsDiff)
}
@Test
func theToolSetIsRegisteredWithoutShadowingABuiltIn() async throws {
let workspace = Fixture.workspace()
let local = Fixture.scratchTools(workspace: workspace)
#expect(
local.map(\.descriptor.name).sorted() == [
"scratch_copy", "scratch_delete", "scratch_diff", "scratch_list", "scratch_move",
"scratch_read", "scratch_replace", "scratch_search", "scratch_write",
])
// Every purely local scratch tool is parallel and pre-approved.
#expect(local.allSatisfy { $0.descriptor.concurrency == .parallel })
#expect(local.allSatisfy { $0.descriptor.target == .local })
#expect(local.allSatisfy { $0.descriptor.approvalPolicy == .approve })
let search = try #require(local.first { $0.descriptor.name == "scratch_search" })
let properties = search.descriptor.inputSchema.objectValue?["properties"]?.objectValue
#expect(
properties?.keys.sorted() == [
"case_sensitive", "limit", "path", "query", "regex",
])
#expect(search.descriptor.presentation?.actionKind == .search)
// Transfer is absent until a connection pool is supplied, on the same terms
// as `web_search`: a tool that cannot work is not offered.
let withHosts = Fixture.scratchTools(
workspace: workspace, connections: Fixture.pool(StubTransferSFTPClient()),
authentication: Fixture.authentication()
)
let transfer = withHosts.filter { ["scratch_pull", "scratch_push"].contains($0.descriptor.name) }
#expect(transfer.count == 2)
#expect(transfer.allSatisfy { $0.descriptor.target == .host })
#expect(
transfer.first { $0.descriptor.name == "scratch_push" }?.descriptor.approvalPolicy
== .ask
)
#expect(
transfer.first { $0.descriptor.name == "scratch_pull" }?.descriptor.approvalPolicy
== .approve
)
// A pull is a remote read landing in contained storage and may overlap
// anything; a push changes a host and takes its turn serial.
#expect(
transfer.first { $0.descriptor.name == "scratch_pull" }?.descriptor.concurrency
== .parallel
)
#expect(
transfer.first { $0.descriptor.name == "scratch_push" }?.descriptor.concurrency
== .sequential
)
// Same terms again for the web: absent without a client, and when present a
// network read landing in contained storage — no approval, may overlap.
#expect(!local.contains { $0.descriptor.name == "scratch_fetch" })
let withWeb = Fixture.scratchTools(workspace: workspace, web: Fixture.web())
let fetch = try #require(withWeb.first { $0.descriptor.name == "scratch_fetch" })
#expect(fetch.descriptor.target == .network)
#expect(fetch.descriptor.approvalPolicy == .approve)
#expect(fetch.descriptor.concurrency == .parallel)
}
/// `fetch` is a separate tool, not a new mode of `fetch`. The same promise
/// `theSFTPToolsAreUnchanged` makes about `sftp_read` / `sftp_write`.
@Test @MainActor
func theFetchToolIsUnchanged() {
let tools = Fixture.builtInTools(
hosts: AgentHostResolver(store: HostLibraryModel()),
connections: Fixture.pool(StubTransferSFTPClient())
)
let fetch = tools.first { $0.descriptor.name == "fetch" }?.descriptor
let properties = fetch?.inputSchema.objectValue?["properties"]?.objectValue ?? [:]
#expect(properties["scratch_path"] == nil)
#expect(properties.keys.sorted() == ["format", "url"])
#expect(fetch?.approvalPolicy == .approve)
#expect(!tools.contains { $0.descriptor.name.hasPrefix("scratch_") })
}
/// The built-in SFTP tools were deliberately left alone: `scratch_pull` and
/// `scratch_push` are separate tools, not new modes of existing ones.
@Test @MainActor
func theSFTPToolsAreUnchanged() {
let tools = Fixture.builtInTools(
hosts: AgentHostResolver(store: HostLibraryModel()),
connections: Fixture.pool(StubTransferSFTPClient())
)
let names = tools.map(\.descriptor.name)
#expect(!names.contains { $0.hasPrefix("scratch_") })
for name in ["sftp_read", "sftp_write"] {
let properties =
tools.first { $0.descriptor.name == name }?
.descriptor.inputSchema.objectValue?["properties"]?.objectValue ?? [:]
#expect(properties["to_scratch"] == nil)
#expect(properties["from_scratch"] == nil)
}
#expect(
tools.first { $0.descriptor.name == "sftp_write" }?.descriptor.inputSchema
.objectValue?["required"]?.arrayValue?.compactMap(\.stringValue).sorted()
== ["content", "host_id", "path"]
)
}
@Test
func searchToolBoundsItsEncodedTranscriptResult() async throws {
let workspace = Fixture.workspace()
let line = String(repeating: "\u{0001}", count: 1_100) + "target"
let body = Array(repeating: line, count: 100).joined(separator: "\n")
_ = try await workspace.write("large.txt", data: Data(body.utf8))
let tool = try #require(
Fixture.scratchTools(workspace: workspace)
.first { $0.descriptor.name == "scratch_search" }
)
let result = try await tool.execute(
Fixture.invocation(
"scratch_search",
[
"query": .string("target"), "limit": .number(100),
]), context: Fixture.context()
)
let payload = try #require(result.untrustedPayload)
let object = try #require(
try AgentJSONValue.decode(Data(payload.utf8)).objectValue
)
#expect(payload.utf8.count <= 128 * 1_024)
#expect(result.isTruncated)
#expect(object["matching_lines"]?.integerValue == 100)
#expect((object["returned_matches"]?.integerValue ?? 100) < 100)
#expect(object["truncated"]?.boolValue == true)
}
// MARK: - Transfer
@Test
func pullLandsTheFileWithoutPuttingItInTheResult() async throws {
let workspace = Fixture.workspace()
let body = (1...50).map { "line \($0)" }.joined(separator: "\n") + "\n"
let client = StubTransferSFTPClient(files: ["/etc/app.conf": Data(body.utf8)])
let tool = try #require(
Fixture.scratchTools(
workspace: workspace, connections: Fixture.pool(client),
authentication: Fixture.authentication()
)
.first { $0.descriptor.name == "scratch_pull" }
)
let invocation = Fixture.invocation(
"scratch_pull",
[
"host_id": .string(Fixture.hostID.uuidString),
"remote_path": .string("/etc/app.conf"), "scratch_path": .string("app.conf"),
])
let result = try await tool.execute(invocation, context: Fixture.context())
#expect(!result.isError)
#expect(!result.content.contains("line 1"))
#expect(result.content.contains("\"lines\":50"))
#expect(result.content.contains(AgentScratchWorkspace.digest(Data(body.utf8))))
// The bytes are in the workspace, byte for byte.
#expect(try await workspace.data(at: "app.conf").data == Data(body.utf8))
}
@Test
func pullValidatesBothPathsBeforeTouchingTheHost() async throws {
let client = StubTransferSFTPClient(files: ["/etc/app.conf": Data("x".utf8)])
let tool = try #require(
Fixture.scratchTools(
workspace: Fixture.workspace(), connections: Fixture.pool(client),
authentication: Fixture.authentication()
).first { $0.descriptor.name == "scratch_pull" }
)
for (remote, scratch) in [("relative/path", "ok.conf"), ("/etc/app.conf", "../out.conf")] {
await #expect(throws: (any Error).self) {
try await tool.preflight(
Fixture.invocation(
"scratch_pull",
[
"host_id": .string(Fixture.hostID.uuidString),
"remote_path": .string(remote), "scratch_path": .string(scratch),
]))
}
}
#expect(await client.reads.isEmpty)
}
/// The regression this guards: `scratch_push` would otherwise turn an approval
/// that showed the bytes into one that showed a filename, and the reviewer —
/// human or model — would be agreeing to something neither had seen.
@Test
func pushPutsRealEvidenceInFrontOfTheApprover() async throws {
let workspace = Fixture.workspace()
let body = "user = admin\npassword = hunter2\nport = 8080\n"
_ = try await workspace.write("app.conf", data: Data(body.utf8))
let client = StubTransferSFTPClient()
let tool = try #require(
Fixture.scratchTools(
workspace: workspace, connections: Fixture.pool(client),
authentication: Fixture.authentication()
)
.first { $0.descriptor.name == "scratch_push" }
)
let invocation = Fixture.invocation(
"scratch_push",
[
"host_id": .string(Fixture.hostID.uuidString),
"scratch_path": .string("app.conf"), "remote_path": .string("/etc/app.conf"),
])
let preflight = try await tool.preflight(invocation)
let reasons = preflight.reasons.joined(separator: "\n")
#expect(preflight.approvalPolicy == .ask)
#expect(preflight.invocation.targetHostID == Fixture.hostID)
#expect(reasons.contains("app.conf"))
#expect(reasons.contains("\(body.utf8.count) bytes"))
#expect(reasons.contains("3 lines"))
#expect(reasons.contains(AgentScratchWorkspace.digest(Data(body.utf8)).prefix(16)))
#expect(reasons.contains("user = admin"))
// The head is a preview shown to a person, so it goes through the same
// redaction the arguments do.
#expect(!reasons.contains("hunter2"))
#expect(reasons.contains("[REDACTED]"))
// The digest binding is execution-only: not an argument, not replayed to
// the model, not persisted.
#expect(preflight.executionMetadata["scratch_sha256"]?.stringValue != nil)
#expect(preflight.invocation.call.arguments.objectValue?["scratch_sha256"] == nil)
}
@Test
func pushWritesTheApprovedBytesAndRefusesAnySubstitute() async throws {
let workspace = Fixture.workspace()
_ = try await workspace.write("app.conf", data: Data("approved\n".utf8))
let client = StubTransferSFTPClient()
let tool = try #require(
Fixture.scratchTools(
workspace: workspace, connections: Fixture.pool(client),
authentication: Fixture.authentication()
)
.first { $0.descriptor.name == "scratch_push" }
)
let invocation = Fixture.invocation(
"scratch_push",
[
"host_id": .string(Fixture.hostID.uuidString),
"scratch_path": .string("app.conf"), "remote_path": .string("/etc/app.conf"),
])
let approved = try await tool.preflight(invocation).executionMetadata
let result = try await tool.execute(
invocation, context: Fixture.context(preflight: approved)
)
#expect(!result.isError)
#expect(await client.writes["/etc/app.conf"] == Data("approved\n".utf8))
// Approval suspends the turn. Whatever comes back from the workspace after
// it is only the same file if nothing rewrote it in between.
_ = try await workspace.write("app.conf", data: Data("swapped\n".utf8))
await #expect(throws: (any Error).self) {
try await tool.execute(invocation, context: Fixture.context(preflight: approved))
}
#expect(await client.writes["/etc/app.conf"] == Data("approved\n".utf8))
}
@Test
func nonTextFilesSurviveTheRoundTripIntact() async throws {
let workspace = Fixture.workspace()
let binary = Data((0...255).map { UInt8($0) })
let client = StubTransferSFTPClient(files: ["/opt/blob.bin": binary])
let tools = Fixture.scratchTools(
workspace: workspace, connections: Fixture.pool(client),
authentication: Fixture.authentication()
)
let pull = try #require(tools.first { $0.descriptor.name == "scratch_pull" })
let push = try #require(tools.first { $0.descriptor.name == "scratch_push" })
_ = try await pull.execute(
Fixture.invocation(
"scratch_pull",
[
"host_id": .string(Fixture.hostID.uuidString),
"remote_path": .string("/opt/blob.bin"), "scratch_path": .string("blob.bin"),
]),
context: Fixture.context()
)
let invocation = Fixture.invocation(
"scratch_push",
[
"host_id": .string(Fixture.hostID.uuidString),
"scratch_path": .string("blob.bin"), "remote_path": .string("/opt/copy.bin"),
])
_ = try await push.execute(
invocation,
context: Fixture.context(preflight: try await push.preflight(invocation).executionMetadata)
)
#expect(await client.writes["/opt/copy.bin"] == binary)
}
// MARK: - Web
@Test
func fetchLandsThePageWithoutPuttingItInTheResult() async throws {
let workspace = Fixture.workspace()
let page = (1...40).map { "line \($0)" }.joined(separator: "\n") + "\n"
let tool = try #require(
Fixture.scratchTools(workspace: workspace, web: Fixture.web(markdown: page))
.first { $0.descriptor.name == "scratch_fetch" }
)
let result = try await tool.execute(
Fixture.invocation(
"scratch_fetch",
[
"url": .string("https://example.com/docs"),
"scratch_path": .string("docs/page.md"),
]),
context: Fixture.context()
)
#expect(!result.isError)
#expect(!result.content.contains("line 1"))
#expect(!result.content.contains("raw-marker"))
#expect(result.content.contains("\"lines\":40"))
#expect(result.content.contains("\"title\":\"Example\""))
#expect(result.content.contains(AgentScratchWorkspace.digest(Data(page.utf8))))
// The bytes are in the workspace, byte for byte.
#expect(try await workspace.data(at: "docs/page.md").data == Data(page.utf8))
}
/// The reason the tool exists. `fetch` truncates at 256 KB because that is what
/// crosses the context window; nothing does here, so the page arrives whole.
@Test
func fetchIsNotBoundedByTheInlineCeilingThatBoundsTheResultOfFetch() async throws {
let workspace = Fixture.workspace()
let oversized = String(repeating: "x", count: 256 * 1_024 + 100)
let tool = try #require(
Fixture.scratchTools(workspace: workspace, web: Fixture.web(markdown: oversized))
.first { $0.descriptor.name == "scratch_fetch" }
)
let result = try await tool.execute(
Fixture.invocation(
"scratch_fetch",
[
"url": .string("https://example.com/big"), "scratch_path": .string("big.md"),
]),
context: Fixture.context()
)
#expect(!result.isTruncated)
#expect(result.content.contains("\"bytes\":\(oversized.utf8.count)"))
#expect(try await workspace.data(at: "big.md").bytes == oversized.utf8.count)
}
@Test
func fetchWritesTheOriginalHTMLWhenAsked() async throws {
let workspace = Fixture.workspace()
let tool = try #require(
Fixture.scratchTools(workspace: workspace, web: Fixture.web())
.first { $0.descriptor.name == "scratch_fetch" }
)
let result = try await tool.execute(
Fixture.invocation(
"scratch_fetch",
[
"url": .string("https://example.com/"), "scratch_path": .string("page.html"),
"format": .string("original"),
]),
context: Fixture.context()
)
#expect(result.content.contains("\"format\":\"original\""))
#expect(!result.content.contains("raw-marker"))
let staged = try await workspace.data(at: "page.html")
#expect(String(data: staged.data, encoding: .utf8)?.contains("raw-marker") == true)
}
@Test
func fetchValidatesTheURLAndTheDestinationBeforeReachingTheNetwork() async throws {
let loaded = Counter()
let web = ScrubberWebClient(
document: { url in
await loaded.increment()
return AgentWebDocument(
title: "Example", url: url, markdown: "# Example", original: "<html/>"
)
},
search: { _, _ in [] }
)
let tool = try #require(
Fixture.scratchTools(workspace: Fixture.workspace(), web: web)
.first { $0.descriptor.name == "scratch_fetch" }
)
for arguments in [
["url": "file:///etc/passwd", "scratch_path": "leak.txt"],
["url": "https://user:pass@example.com/", "scratch_path": "creds.md"],
["url": "https://example.com/", "scratch_path": "../escape.md"],
["url": "https://example.com/", "scratch_path": "/etc/passwd"],
] {
await #expect(throws: (any Error).self) {
try await tool.preflight(
Fixture.invocation(
"scratch_fetch", arguments.mapValues(AgentJSONValue.string)
))
}
}
#expect(await loaded.count == 0)
}
/// An empty representation means extraction failed, not that the page is empty.
@Test
func fetchRefusesToStageAnEmptyExtraction() async throws {
let workspace = Fixture.workspace()
let tool = try #require(
Fixture.scratchTools(workspace: workspace, web: Fixture.web(markdown: ""))
.first { $0.descriptor.name == "scratch_fetch" }
)
await #expect(throws: (any Error).self) {
try await tool.execute(
Fixture.invocation(
"scratch_fetch",
[
"url": .string("https://example.com/js-only"),
"scratch_path": .string("empty.md"),
]),
context: Fixture.context()
)
}
#expect(try await workspace.usage().entries == 0)
}
}
/// Counts calls from a `@Sendable` closure without a data race.
private actor Counter {
private(set) var count = 0
func increment() { count += 1 }
}
// MARK: - Fixtures
private enum Fixture {
static func scratchTools(
workspace: AgentScratchWorkspace,
connections: AgentConnectionPool? = nil,
authentication: AgentSSHAuthenticationRegistry? = nil,
web: ScrubberWebClient? = nil
) -> [AnyAgentTool] {
var tools = [
AnyAgentTool(ScratchListTool(workspace: workspace)),
AnyAgentTool(ScratchReadTool(workspace: workspace)),
AnyAgentTool(ScratchSearchTool(workspace: workspace)),
AnyAgentTool(ScratchWriteTool(workspace: workspace)),
AnyAgentTool(ScratchReplaceTool(workspace: workspace)),
AnyAgentTool(ScratchCopyTool(workspace: workspace)),
AnyAgentTool(ScratchMoveTool(workspace: workspace)),
AnyAgentTool(ScratchDiffTool(workspace: workspace)),
AnyAgentTool(ScratchDeleteTool(workspace: workspace)),
]
if let connections, let authentication {
tools += [
AnyAgentTool(
ScratchPullTool(
workspace: workspace, pool: connections, authentication: authentication
)),
AnyAgentTool(
ScratchPushTool(
workspace: workspace, pool: connections, authentication: authentication
)),
]
}
if let web { tools.append(AnyAgentTool(ScratchFetchTool(workspace: workspace, web: web))) }
return tools
}
@MainActor
static func builtInTools(
hosts: AgentHostResolver, connections: AgentConnectionPool
) -> [AnyAgentTool] {
// A fetcher but no workspace: the point of every caller is that the
// scratch tools are absent while `fetch` is not.
AgentToolCatalog.registry(
builtIn: .init(web: web()),
additional: TermindAgentTools.tools(
in: .init(
hosts: hosts, metrics: TestMetricsReader(), connections: connections
))
).registeredTools
}
static let hostID = UUID(uuidString: "F0000000-0000-0000-0000-00000000000F")!
static func base() -> URL {
let url = FileManager.default.temporaryDirectory
.appending(path: "termind-scratch-tests/\(UUID().uuidString)")
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
return url
}
static func workspace() -> AgentScratchWorkspace {
AgentScratchWorkspace(conversationID: UUID(), base: base())
}
static func pool(_ client: StubTransferSFTPClient) -> AgentConnectionPool {
AgentConnectionPool(
configuration: { hostID in
SSHConnectionConfiguration(
hostID: hostID, host: "host.example", port: 22, username: "root",
credentials: [], expectedHostKeys: [], proxy: nil, jumps: []
)
},
routeFingerprint: { _ in "fingerprint" },
openExec: { _ in throw AgentToolError.invalidHost },
openSFTP: { _ in client },
sweepInterval: nil
)
}
static func web(markdown: String = "# Example") -> ScrubberWebClient {
ScrubberWebClient(
document: { url in
AgentWebDocument(
title: "Example", url: url, markdown: markdown,
original: "<html><body>raw-marker</body></html>"
)
},
search: { _, _ in [] }
)
}
@MainActor
static func authentication() -> AgentSSHAuthenticationRegistry {
AgentSSHAuthenticationRegistry(
resolver: AgentHostResolver(store: HostLibraryModel())
)
}
static func invocation(
_ name: String, _ arguments: [String: AgentJSONValue]
) -> AgentToolInvocation {
AgentToolInvocation(
runID: UUID(),
call: AgentToolCall(id: "call-1", name: name, arguments: .object(arguments))
)
}
static func context(
preflight: [String: AgentJSONValue] = [:]
) -> AgentToolExecutionContext {
AgentToolExecutionContext(
runID: UUID(), userIntent: "update the config", secretBroker: SecretBroker(),
userInteraction: UnavailableAgentUserInteraction(),
preflightMetadata: preflight, authorize: { _ in .allow }
)
}
static func request(
approvalPolicy: AgentToolDescriptor.ApprovalPolicy
) -> AgentApprovalRequest {
AgentApprovalRequest(
invocation: invocation("t", [:]),
descriptor: AgentToolDescriptor(
name: "t", summary: "t",
inputSchema: .object(["type": .string("object")]),
target: .local, approvalPolicy: approvalPolicy
),
userIntent: "do the thing"
)
}
}
private actor CountingSecurityReviewer: SecurityReviewing {
private(set) var count = 0
func review(_ request: AgentApprovalRequest) async throws -> SecurityReviewDecision {
count += 1
return SecurityReviewDecision(
verdict: .approve, risk: .low, userAuthorization: .sufficient, reason: "fine"
)
}
}
private actor StubTransferSFTPClient: SFTPClientProtocol {
private(set) var files: [String: Data]
private(set) var writes: [String: Data] = [:]
private(set) var reads: [String] = []
init(files: [String: Data] = [:]) { self.files = files }
func readFile(at path: String, maximumBytes: Int) async throws -> Data {
reads.append(path)
guard let data = files[path] else { throw AgentToolError.notFound }
guard data.count <= maximumBytes else {
throw SFTPError.operation("\(path) exceeds \(maximumBytes) bytes.")
}
return data
}
func writeFile(_ data: Data, at path: String, overwrite: Bool) async throws {
guard overwrite || writes[path] == nil else {
throw SFTPError.operation("\(path) already exists.")
}
writes[path] = data
}
func connect(
using configuration: SSHConnectionConfiguration, progress: SSHRouteProgressHandler?
) async throws {}
func disconnect() async {}
func homeDirectory() async throws -> String { "/root" }
func listDirectory(at path: String) async throws -> [RemoteFile] { [] }
func attributes(at path: String) async throws -> RemoteFile? { nil }
func setPermissions(at path: String, to permissions: UInt32) async throws {}
func createDirectory(at path: String) async throws {}
func rename(from source: String, to destination: String, overwrite: Bool) async throws {}
func removeFile(at path: String) async throws {}
func removeDirectory(at path: String) async throws {}
func uploadFile(
from localURL: URL, to remotePath: String, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
func downloadFile(
from remotePath: String, to localURL: URL, overwrite: Bool,
cancellation: TransferCancellationToken,
progress: @escaping @Sendable (SFTPProgress) -> Void
) async throws {}
}