diff --git a/README.md b/README.md index 43e4836..9e11f03 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,9 @@ Then add the product to your target: - `JavaScriptExecutionCall.result` - `JavaScriptExecutionCall.cancel()` - `CodeModeToolError` +- `CodeModeConfiguration` +- `CodeModeFileSystem` +- `LocalCodeModeFileSystem` - `CodeModeAgentToolDescriptions` ## Quick Start @@ -103,6 +106,20 @@ let result = try await call.result print(result.output ?? .null) ``` +## Filesystem Integration + +CodeMode keeps its JavaScript filesystem API stable while allowing hosts to provide the underlying operations: + +```swift +let tools = CodeModeAgentTools( + config: CodeModeConfiguration( + fileSystem: MyCodeModeFileSystem() + ) +) +``` + +`CodeModeFileSystem` receives paths after `PathPolicy` resolution, so sandbox root enforcement stays in CodeMode while the host can route reads, writes, listings, moves, copies, deletes, and stats through another backing implementation. `LocalCodeModeFileSystem` preserves the default `FileManager` behavior. + ## Search `searchJavaScriptAPI` accepts `JavaScriptAPISearchRequest`: diff --git a/Sources/CodeMode/API/BridgeModels.swift b/Sources/CodeMode/API/BridgeModels.swift index 08e44bc..b26e9e1 100644 --- a/Sources/CodeMode/API/BridgeModels.swift +++ b/Sources/CodeMode/API/BridgeModels.swift @@ -2,17 +2,20 @@ import Foundation public struct CodeModeConfiguration: Sendable { public var pathPolicy: any PathPolicy + public var fileSystem: any CodeModeFileSystem public var artifactStore: any ArtifactStore public var permissionBroker: any PermissionBroker public var auditLogger: any AuditLogger public init( pathPolicy: any PathPolicy = DefaultPathPolicy(), + fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem(), artifactStore: any ArtifactStore = InMemoryArtifactStore(), permissionBroker: any PermissionBroker = SystemPermissionBroker(), auditLogger: any AuditLogger = SyncAuditLogger() ) { self.pathPolicy = pathPolicy + self.fileSystem = fileSystem self.artifactStore = artifactStore self.permissionBroker = permissionBroker self.auditLogger = auditLogger diff --git a/Sources/CodeMode/Bridges/CodeModeFileSystem.swift b/Sources/CodeMode/Bridges/CodeModeFileSystem.swift new file mode 100644 index 0000000..02057ea --- /dev/null +++ b/Sources/CodeMode/Bridges/CodeModeFileSystem.swift @@ -0,0 +1,124 @@ +import Foundation + +public struct CodeModeFileSystemEntry: Sendable, Equatable { + public var name: String + public var path: String + public var isDirectory: Bool + public var size: Int + + public init(name: String, path: String, isDirectory: Bool, size: Int) { + self.name = name + self.path = path + self.isDirectory = isDirectory + self.size = size + } +} + +public struct CodeModeFileSystemAttributes: Sendable, Equatable { + public var isDirectory: Bool + public var size: Int + public var creationDate: Date? + public var modificationDate: Date? + + public init(isDirectory: Bool, size: Int, creationDate: Date?, modificationDate: Date?) { + self.isDirectory = isDirectory + self.size = size + self.creationDate = creationDate + self.modificationDate = modificationDate + } +} + +public struct CodeModeFileSystemAccess: Sendable, Equatable { + public var readable: Bool + public var writable: Bool + + public init(readable: Bool, writable: Bool) { + self.readable = readable + self.writable = writable + } +} + +public protocol CodeModeFileSystem: Sendable { + func listDirectory(at url: URL) throws -> [CodeModeFileSystemEntry] + func readData(at url: URL) throws -> Data + func writeData(_ data: Data, to url: URL) throws + func moveItem(at sourceURL: URL, to destinationURL: URL) throws + func copyItem(at sourceURL: URL, to destinationURL: URL) throws + func removeItem(at url: URL) throws + func attributesOfItem(at url: URL) throws -> CodeModeFileSystemAttributes + func createDirectory(at url: URL, recursive: Bool) throws + func itemExists(at url: URL) -> Bool + func access(at url: URL) -> CodeModeFileSystemAccess +} + +public final class LocalCodeModeFileSystem: CodeModeFileSystem, @unchecked Sendable { + private let fileManager: FileManager + + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + public func listDirectory(at url: URL) throws -> [CodeModeFileSystemEntry] { + let values = try fileManager.contentsOfDirectory( + at: url, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) + + return try values.map { item in + let resourceValues = try item.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) + return CodeModeFileSystemEntry( + name: item.lastPathComponent, + path: item.path, + isDirectory: resourceValues.isDirectory ?? false, + size: resourceValues.fileSize ?? 0 + ) + } + } + + public func readData(at url: URL) throws -> Data { + try Data(contentsOf: url) + } + + public func writeData(_ data: Data, to url: URL) throws { + try data.write(to: url, options: .atomic) + } + + public func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + try fileManager.moveItem(at: sourceURL, to: destinationURL) + } + + public func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + try fileManager.copyItem(at: sourceURL, to: destinationURL) + } + + public func removeItem(at url: URL) throws { + try fileManager.removeItem(at: url) + } + + public func attributesOfItem(at url: URL) throws -> CodeModeFileSystemAttributes { + let attrs = try fileManager.attributesOfItem(atPath: url.path) + let type = attrs[.type] as? FileAttributeType + return CodeModeFileSystemAttributes( + isDirectory: type == .typeDirectory, + size: (attrs[.size] as? NSNumber)?.intValue ?? 0, + creationDate: attrs[.creationDate] as? Date, + modificationDate: attrs[.modificationDate] as? Date + ) + } + + public func createDirectory(at url: URL, recursive: Bool) throws { + try fileManager.createDirectory(at: url, withIntermediateDirectories: recursive) + } + + public func itemExists(at url: URL) -> Bool { + fileManager.fileExists(atPath: url.path) + } + + public func access(at url: URL) -> CodeModeFileSystemAccess { + CodeModeFileSystemAccess( + readable: fileManager.isReadableFile(atPath: url.path), + writable: fileManager.isWritableFile(atPath: url.path) + ) + } +} diff --git a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift index 41f7685..f77d9e5 100644 --- a/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift +++ b/Sources/CodeMode/Bridges/DefaultCapabilityLoader.swift @@ -1,8 +1,10 @@ import Foundation public enum DefaultCapabilityLoader { - public static func loadAllRegistrations() -> [CapabilityRegistration] { - let fs = FileSystemBridge() + public static func loadAllRegistrations( + fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem() + ) -> [CapabilityRegistration] { + let fs = FileSystemBridge(fileSystem: fileSystem) let network = NetworkBridge() let keychain = KeychainBridge() let location = LocationBridge() diff --git a/Sources/CodeMode/Bridges/FileSystemBridge.swift b/Sources/CodeMode/Bridges/FileSystemBridge.swift index 3c83164..a4b8be9 100644 --- a/Sources/CodeMode/Bridges/FileSystemBridge.swift +++ b/Sources/CodeMode/Bridges/FileSystemBridge.swift @@ -1,10 +1,14 @@ import Foundation public final class FileSystemBridge: @unchecked Sendable { - private let fileManager: FileManager + private let fileSystem: any CodeModeFileSystem - public init(fileManager: FileManager = .default) { - self.fileManager = fileManager + public init(fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem()) { + self.fileSystem = fileSystem + } + + public convenience init(fileManager: FileManager) { + self.init(fileSystem: LocalCodeModeFileSystem(fileManager: fileManager)) } public func list(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { @@ -13,14 +17,12 @@ public final class FileSystemBridge: @unchecked Sendable { } let url = try context.pathPolicy.resolve(path: path) - let values = try fileManager.contentsOfDirectory(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], options: [.skipsHiddenFiles]) - let entries: [JSONValue] = try values.map { item in - let resourceValues = try item.resourceValues(forKeys: [.isDirectoryKey, .fileSizeKey]) + let entries: [JSONValue] = try fileSystem.listDirectory(at: url).map { item in return .object([ - "name": .string(item.lastPathComponent), + "name": .string(item.name), "path": .string(item.path), - "isDirectory": .bool(resourceValues.isDirectory ?? false), - "size": .number(Double(resourceValues.fileSize ?? 0)), + "isDirectory": .bool(item.isDirectory), + "size": .number(Double(item.size)), ]) } @@ -34,7 +36,7 @@ public final class FileSystemBridge: @unchecked Sendable { let encoding = arguments.string("encoding") ?? "utf8" let url = try context.pathPolicy.resolve(path: path) - let data = try Data(contentsOf: url) + let data = try fileSystem.readData(at: url) switch encoding.lowercased() { case "utf8", "utf-8": @@ -62,7 +64,7 @@ public final class FileSystemBridge: @unchecked Sendable { let url = try context.pathPolicy.resolve(path: path) let parent = url.deletingLastPathComponent() - try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + try fileSystem.createDirectory(at: parent, recursive: true) let data: Data switch encoding.lowercased() { @@ -78,7 +80,7 @@ public final class FileSystemBridge: @unchecked Sendable { throw BridgeError.invalidArguments("Unsupported encoding: \(encoding)") } - try data.write(to: url, options: .atomic) + try fileSystem.writeData(data, to: url) return .object([ "path": .string(url.path), "bytesWritten": .number(Double(data.count)), @@ -93,11 +95,11 @@ public final class FileSystemBridge: @unchecked Sendable { let fromURL = try context.pathPolicy.resolve(path: from) let toURL = try context.pathPolicy.resolve(path: to) - if fileManager.fileExists(atPath: toURL.path) { - try fileManager.removeItem(at: toURL) + if fileSystem.itemExists(at: toURL) { + try fileSystem.removeItem(at: toURL) } - try fileManager.moveItem(at: fromURL, to: toURL) + try fileSystem.moveItem(at: fromURL, to: toURL) return .object([ "from": .string(fromURL.path), "to": .string(toURL.path), @@ -112,11 +114,11 @@ public final class FileSystemBridge: @unchecked Sendable { let fromURL = try context.pathPolicy.resolve(path: from) let toURL = try context.pathPolicy.resolve(path: to) - if fileManager.fileExists(atPath: toURL.path) { - try fileManager.removeItem(at: toURL) + if fileSystem.itemExists(at: toURL) { + try fileSystem.removeItem(at: toURL) } - try fileManager.copyItem(at: fromURL, to: toURL) + try fileSystem.copyItem(at: fromURL, to: toURL) return .object([ "from": .string(fromURL.path), "to": .string(toURL.path), @@ -130,17 +132,17 @@ public final class FileSystemBridge: @unchecked Sendable { let recursive = arguments.bool("recursive") ?? false let url = try context.pathPolicy.resolve(path: path) - var isDirectory: ObjCBool = false - guard fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + guard fileSystem.itemExists(at: url) else { return .object(["deleted": .bool(false), "path": .string(url.path)]) } - if isDirectory.boolValue, recursive == false { + let attrs = try fileSystem.attributesOfItem(at: url) + if attrs.isDirectory, recursive == false { throw BridgeError.invalidArguments("fs.delete requires recursive=true for directories") } - try fileManager.removeItem(at: url) + try fileSystem.removeItem(at: url) return .object(["deleted": .bool(true), "path": .string(url.path)]) } @@ -150,16 +152,14 @@ public final class FileSystemBridge: @unchecked Sendable { } let url = try context.pathPolicy.resolve(path: path) - let attrs = try fileManager.attributesOfItem(atPath: url.path) - let type = attrs[.type] as? FileAttributeType - let isDirectory = type == .typeDirectory + let attrs = try fileSystem.attributesOfItem(at: url) return .object([ "path": .string(url.path), - "isDirectory": .bool(isDirectory), - "size": .number(Double((attrs[.size] as? NSNumber)?.intValue ?? 0)), - "createdAt": .string((attrs[.creationDate] as? Date)?.ISO8601Format() ?? ""), - "modifiedAt": .string((attrs[.modificationDate] as? Date)?.ISO8601Format() ?? ""), + "isDirectory": .bool(attrs.isDirectory), + "size": .number(Double(attrs.size)), + "createdAt": .string(attrs.creationDate?.ISO8601Format() ?? ""), + "modifiedAt": .string(attrs.modificationDate?.ISO8601Format() ?? ""), ]) } @@ -170,7 +170,7 @@ public final class FileSystemBridge: @unchecked Sendable { let recursive = arguments.bool("recursive") ?? true let url = try context.pathPolicy.resolve(path: path) - try fileManager.createDirectory(at: url, withIntermediateDirectories: recursive) + try fileSystem.createDirectory(at: url, recursive: recursive) return .object(["created": .bool(true), "path": .string(url.path)]) } @@ -181,7 +181,7 @@ public final class FileSystemBridge: @unchecked Sendable { } let url = try context.pathPolicy.resolve(path: path) - return .bool(fileManager.fileExists(atPath: url.path)) + return .bool(fileSystem.itemExists(at: url)) } public func access(arguments: [String: JSONValue], context: BridgeInvocationContext) throws -> JSONValue { @@ -190,11 +190,10 @@ public final class FileSystemBridge: @unchecked Sendable { } let url = try context.pathPolicy.resolve(path: path) - let readable = fileManager.isReadableFile(atPath: url.path) - let writable = fileManager.isWritableFile(atPath: url.path) + let access = fileSystem.access(at: url) return .object([ - "readable": .bool(readable), - "writable": .bool(writable), + "readable": .bool(access.readable), + "writable": .bool(access.writable), "path": .string(url.path), ]) } diff --git a/Sources/CodeMode/Host/CodeModeAgentTools.swift b/Sources/CodeMode/Host/CodeModeAgentTools.swift index 3c7a6ea..cd730dc 100644 --- a/Sources/CodeMode/Host/CodeModeAgentTools.swift +++ b/Sources/CodeMode/Host/CodeModeAgentTools.swift @@ -7,7 +7,7 @@ public final class CodeModeAgentTools: @unchecked Sendable { public init(config: CodeModeConfiguration = .init()) { let registrations = CapabilityPlatformSupport.filter( - DefaultCapabilityLoader.loadAllRegistrations(), + DefaultCapabilityLoader.loadAllRegistrations(fileSystem: config.fileSystem), for: .current ) let registry = CapabilityRegistry(registrations: registrations) diff --git a/Tests/CodeModeTests/FileSystemBridgeTests.swift b/Tests/CodeModeTests/FileSystemBridgeTests.swift index c994722..6dd2b94 100644 --- a/Tests/CodeModeTests/FileSystemBridgeTests.swift +++ b/Tests/CodeModeTests/FileSystemBridgeTests.swift @@ -2,6 +2,74 @@ import Foundation import Testing @testable import CodeMode +private final class RecordingCodeModeFileSystem: CodeModeFileSystem, @unchecked Sendable { + private let base = LocalCodeModeFileSystem() + private let lock = NSLock() + private var recordedCalls: [String] = [] + + var calls: [String] { + lock.lock() + defer { lock.unlock() } + return recordedCalls + } + + func listDirectory(at url: URL) throws -> [CodeModeFileSystemEntry] { + record("listDirectory") + return try base.listDirectory(at: url) + } + + func readData(at url: URL) throws -> Data { + record("readData") + return try base.readData(at: url) + } + + func writeData(_ data: Data, to url: URL) throws { + record("writeData") + try base.writeData(data, to: url) + } + + func moveItem(at sourceURL: URL, to destinationURL: URL) throws { + record("moveItem") + try base.moveItem(at: sourceURL, to: destinationURL) + } + + func copyItem(at sourceURL: URL, to destinationURL: URL) throws { + record("copyItem") + try base.copyItem(at: sourceURL, to: destinationURL) + } + + func removeItem(at url: URL) throws { + record("removeItem") + try base.removeItem(at: url) + } + + func attributesOfItem(at url: URL) throws -> CodeModeFileSystemAttributes { + record("attributesOfItem") + return try base.attributesOfItem(at: url) + } + + func createDirectory(at url: URL, recursive: Bool) throws { + record("createDirectory") + try base.createDirectory(at: url, recursive: recursive) + } + + func itemExists(at url: URL) -> Bool { + record("itemExists") + return base.itemExists(at: url) + } + + func access(at url: URL) -> CodeModeFileSystemAccess { + record("access") + return base.access(at: url) + } + + private func record(_ call: String) { + lock.lock() + recordedCalls.append(call) + lock.unlock() + } +} + @Test func fileSystemRoundTripOperations() throws { let fs = FileSystemBridge() let (context, sandbox) = try makeInvocationContext() @@ -100,6 +168,34 @@ import Testing } } +@Test func executeUsesConfiguredFileSystemOperations() async throws { + let fileSystem = RecordingCodeModeFileSystem() + let (tools, sandbox) = try makeTools(fileSystem: fileSystem) + defer { cleanup(sandbox) } + + let observed = try await execute( + tools, + request: JavaScriptExecutionRequest( + code: """ + await apple.fs.write({ path: 'tmp:custom-fs.txt', data: 'configured' }); + const text = await fs.promises.readFile('tmp:custom-fs.txt', 'utf8'); + const stat = await fs.promises.stat('tmp:custom-fs.txt'); + const entries = await fs.promises.readdir('tmp:'); + return { text, size: stat.size, count: entries.length }; + """, + allowedCapabilities: [.fsWrite, .fsRead, .fsStat, .fsList] + ) + ) + + let payload = try requireJSONObject(from: try #require(observed.result)) + #expect(payload["text"] as? String == "configured") + #expect((payload["size"] as? Double ?? 0) > 0) + #expect((payload["count"] as? Int ?? 0) > 0) + + let calls = Set(fileSystem.calls) + #expect(calls.isSuperset(of: ["createDirectory", "writeData", "readData", "attributesOfItem", "listDirectory"])) +} + @Test func executeUsesFileSystemBridge() async throws { let (tools, sandbox) = try makeTools() defer { cleanup(sandbox) } diff --git a/Tests/CodeModeTests/TestSupport.swift b/Tests/CodeModeTests/TestSupport.swift index 0489cae..ae4d32e 100644 --- a/Tests/CodeModeTests/TestSupport.swift +++ b/Tests/CodeModeTests/TestSupport.swift @@ -33,7 +33,10 @@ func cleanup(_ sandbox: TestSandbox) { try? FileManager.default.removeItem(at: sandbox.root) } -func makeTools(permissionBroker: any PermissionBroker = NoopPermissionBroker()) throws -> (CodeModeAgentTools, TestSandbox) { +func makeTools( + permissionBroker: any PermissionBroker = NoopPermissionBroker(), + fileSystem: any CodeModeFileSystem = LocalCodeModeFileSystem() +) throws -> (CodeModeAgentTools, TestSandbox) { let sandbox = try makeTestSandbox() let pathPolicy = DefaultPathPolicy( @@ -42,6 +45,7 @@ func makeTools(permissionBroker: any PermissionBroker = NoopPermissionBroker()) let configuration = CodeModeConfiguration( pathPolicy: pathPolicy, + fileSystem: fileSystem, artifactStore: InMemoryArtifactStore(), permissionBroker: permissionBroker, auditLogger: SyncAuditLogger()