Add client_read, client_write, client_edit and client_download

This commit is contained in:
Ian Gulliver
2026-10-18 00:13:37 -07:00
parent 2ed0adae90
commit e8599a7fce
7 changed files with 736 additions and 262 deletions
+77 -5
View File
@@ -8,6 +8,8 @@ typealias ClientReply = Components.Schemas.ClientReply
private let retryAfter = Duration.seconds(2)
private let maxOutput = 64 << 10
private let maxMessage = 256 << 20
private let maxRead = 64 << 20
private let copyTimeoutMs = 10 * 60 * 1000
@MainActor
@@ -26,6 +28,7 @@ final class Remote {
return
}
let task = api.session.webSocketTask(with: api.url("/api/clients/connect", scheme: "wss"))
task.maximumMessageSize = maxMessage
ws = task
task.resume()
receive(task)
@@ -93,8 +96,24 @@ final class Remote {
let home = FileManager.default.homeDirectoryForCurrentUser.path
let (code, output) = try await spawn(["/bin/zsh", "-l", "-c", req.command ?? ""], in: home, timeoutMs: req.timeoutMs ?? 120_000)
return ClientReply(id: req.id, exitCode: code, output: output)
case .copy:
try await copy(transfer: req.transfer ?? "", to: req.dest ?? "")
case .upload:
try await upload(transfer: req.transfer ?? "", to: req.dest ?? "")
return ClientReply(id: req.id)
case .download:
try await download(req.path ?? "", transfer: req.transfer ?? "")
return ClientReply(id: req.id)
case .readFile:
let path = req.path ?? ""
let data = try await Task.detached {
try readFile(path)
}.value
return ClientReply(id: req.id, data: Base64EncodedData(data))
case .writeFile:
let path = req.path ?? ""
let data = Data(req.data?.data ?? [])
try await Task.detached {
try writeFile(path, data)
}.value
return ClientReply(id: req.id)
case .hands:
let result = try await hands.call(req.tool ?? "", req.arguments?.additionalProperties ?? OpenAPIObjectContainer())
@@ -102,8 +121,30 @@ final class Remote {
}
}
private func copy(transfer: String, to dest: String) async throws {
let target = URL(filePath: (dest as NSString).expandingTildeInPath)
private func download(_ path: String, transfer: String) async throws {
let source = expand(path)
let archive = FileManager.default.temporaryDirectory.appending(path: "byre-download-\(UUID().uuidString).tgz")
defer {
try? FileManager.default.removeItem(at: archive)
}
let parent = source.deletingLastPathComponent().path
let (status, output) = try await spawn(["/usr/bin/tar", "-czf", archive.path, "-C", parent, "--", source.lastPathComponent], in: parent, timeoutMs: copyTimeoutMs)
if status != 0 {
throw ApiError(status: 0, message: "pack: tar exited \(status): \(output)")
}
var request = URLRequest(url: api.url("/api/clients/transfers/\(transfer)"))
request.httpMethod = "PUT"
request.setValue("application/gzip", forHTTPHeaderField: "Content-Type")
let (body, response) = try await api.session.upload(for: request, fromFile: archive)
let code = (response as! HTTPURLResponse).statusCode
if code != 204 {
throw ApiError(status: code, message: "upload transfer: \(code) \(String(decoding: body, as: UTF8.self))")
}
}
private func upload(transfer: String, to dest: String) async throws {
let target = expand(dest)
let parent = target.deletingLastPathComponent()
let fm = FileManager.default
try fm.createDirectory(at: parent, withIntermediateDirectories: true)
@@ -117,7 +158,7 @@ final class Remote {
throw ApiError(status: code, message: "download transfer: \(code) \(String(decoding: (try? Data(contentsOf: archive)) ?? Data(), as: UTF8.self))")
}
let tmp = parent.appending(path: ".byre-copy-\(UUID().uuidString)", directoryHint: .isDirectory)
let tmp = parent.appending(path: ".byre-upload-\(UUID().uuidString)", directoryHint: .isDirectory)
try fm.createDirectory(at: tmp, withIntermediateDirectories: false)
defer {
try? fm.removeItem(at: tmp)
@@ -137,6 +178,37 @@ final class Remote {
}
}
private func expand(_ path: String) -> URL {
URL(filePath: (path as NSString).expandingTildeInPath)
}
private func readFile(_ path: String) throws -> Data {
let source = expand(path).resolvingSymlinksInPath()
let type = try FileManager.default.attributesOfItem(atPath: source.path)[.type] as? FileAttributeType
guard type == .typeRegular else {
throw ApiError(status: 0, message: "\(path) isn't a regular file")
}
let h = try FileHandle(forReadingFrom: source)
defer {
try? h.close()
}
let data = try h.read(upToCount: maxRead + 1) ?? Data()
if data.count > maxRead {
throw ApiError(status: 0, message: "\(path) is over the \(maxRead >> 20) MiB read limit; use client_download")
}
return data
}
private func writeFile(_ path: String, _ data: Data) throws {
let target = expand(path).resolvingSymlinksInPath()
let fm = FileManager.default
if let attrs = try? fm.attributesOfItem(atPath: target.path), attrs[.type] as? FileAttributeType != .typeRegular {
throw ApiError(status: 0, message: "\(path) isn't a regular file")
}
try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
try data.write(to: target)
}
private final class Flag: @unchecked Sendable {
private let lock = NSLock()
private var value = false