import ByreAPI import Darwin import Foundation import OpenAPIRuntime typealias ClientRequest = Components.Schemas.ClientRequest 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 final class Remote { let api: API private let hands = Hands() private var ws: URLSessionWebSocketTask? private var stopped = false init(api: API) { self.api = api } func start() { guard !stopped else { return } let task = api.session.webSocketTask(with: api.url("/api/clients/connect", scheme: "wss")) task.maximumMessageSize = maxMessage ws = task task.resume() receive(task) } func stop() { stopped = true ws?.cancel(with: .normalClosure, reason: nil) ws = nil hands.stop() } private func receive(_ task: URLSessionWebSocketTask) { task.receive { result in Task { @MainActor in guard self.ws === task, !self.stopped else { return } switch result { case .success(.string(let s)): self.handle(task, Data(s.utf8)) self.receive(task) case .success(.data(let d)): self.handle(task, d) self.receive(task) case .success: self.receive(task) case .failure(let error): log.info("client connection closed: \(errorText(error), privacy: .public)") self.ws = nil try? await Task.sleep(for: retryAfter) self.start() } } } } private func handle(_ task: URLSessionWebSocketTask, _ data: Data) { let req: ClientRequest do { req = try jsonDecoder().decode(ClientRequest.self, from: data) } catch { log.error("decode client request: \(String(describing: error), privacy: .public)") return } log.info("client request \(req.id, privacy: .public): \(req.op.rawValue, privacy: .public)") Task { var reply: ClientReply do { reply = try await perform(req) } catch { reply = ClientReply(id: req.id, error: errorText(error)) } do { try await task.send(.string(String(decoding: try JSONEncoder().encode(reply), as: UTF8.self))) } catch { log.error("send client reply \(req.id, privacy: .public): \(errorText(error), privacy: .public)") } } } private func perform(_ req: ClientRequest) async throws -> ClientReply { switch req.op { case .bash: 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 .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()) return ClientReply(id: req.id, result: .init(additionalProperties: result)) } } 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) let (archive, response) = try await api.session.download(from: api.url("/api/clients/transfers/\(transfer)")) defer { try? fm.removeItem(at: archive) } let code = (response as! HTTPURLResponse).statusCode if code != 200 { throw ApiError(status: code, message: "download transfer: \(code) \(String(decoding: (try? Data(contentsOf: archive)) ?? Data(), as: UTF8.self))") } let tmp = parent.appending(path: ".byre-upload-\(UUID().uuidString)", directoryHint: .isDirectory) try fm.createDirectory(at: tmp, withIntermediateDirectories: false) defer { try? fm.removeItem(at: tmp) } let (status, output) = try await spawn(["/usr/bin/tar", "-xzf", archive.path, "-C", tmp.path], in: tmp.path, timeoutMs: copyTimeoutMs) if status != 0 { throw ApiError(status: 0, message: "unpack: tar exited \(status): \(output)") } let entries = try fm.contentsOfDirectory(at: tmp, includingPropertiesForKeys: nil) guard entries.count == 1 else { throw ApiError(status: 0, message: "unpack: the archive holds \(entries.count) entries, not 1") } if (try? fm.attributesOfItem(atPath: target.path)) != nil { try fm.removeItem(at: target) } try fm.moveItem(at: entries[0], to: target) } } 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 func set() -> Bool { lock.withLock { let was = value value = true return !was } } } private func spawn(_ argv: [String], in dir: String, timeoutMs: Int) async throws -> (Int, String) { let out = FileManager.default.temporaryDirectory.appending(path: "byre-\(UUID().uuidString).log") defer { try? FileManager.default.removeItem(at: out) } var attr: posix_spawnattr_t? unsafe posix_spawnattr_init(&attr) defer { unsafe posix_spawnattr_destroy(&attr) } unsafe posix_spawnattr_setflags(&attr, Int16(POSIX_SPAWN_SETPGROUP)) unsafe posix_spawnattr_setpgroup(&attr, 0) var actions: posix_spawn_file_actions_t? unsafe posix_spawn_file_actions_init(&actions) defer { unsafe posix_spawn_file_actions_destroy(&actions) } unsafe posix_spawn_file_actions_addopen(&actions, 0, "/dev/null", O_RDONLY, 0) unsafe posix_spawn_file_actions_addopen(&actions, 1, out.path, O_WRONLY | O_CREAT | O_TRUNC, 0o600) unsafe posix_spawn_file_actions_adddup2(&actions, 1, 2) unsafe posix_spawn_file_actions_addchdir(&actions, dir) var cargs = unsafe argv.map { unsafe strdup($0) } + [nil] defer { for unsafe p in unsafe cargs { unsafe free(p) } } var spawned: pid_t = 0 let rc = unsafe posix_spawn(&spawned, argv[0], &actions, &attr, &cargs, environ) if rc != 0 { throw ApiError(status: 0, message: "start \(argv[0]): \(unsafe String(cString: strerror(rc)))") } let pid = spawned let done = Flag() DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(timeoutMs)) { if done.set() { kill(-pid, SIGKILL) } } let status: Int32 = await withCheckedContinuation { c in DispatchQueue.global().async { var s: Int32 = 0 while unsafe waitpid(pid, &s, 0) == -1 && errno == EINTR {} c.resume(returning: s) } } let timedOut = !done.set() let output = tail(out) if timedOut { return (-1, output) } if status & 0x7f == 0 { return (Int((status >> 8) & 0xff), output) } return (128 + Int(status & 0x7f), output) } private func tail(_ file: URL) -> String { guard let h = try? FileHandle(forReadingFrom: file) else { return "" } defer { try? h.close() } let size = (try? h.seekToEnd()) ?? 0 try? h.seek(toOffset: size > UInt64(maxOutput) ? size - UInt64(maxOutput) : 0) return String(decoding: (try? h.readToEnd()) ?? Data(), as: UTF8.self) } private struct HandsRequest: Encodable { struct Params: Encodable { let name: String let arguments: OpenAPIObjectContainer } let jsonrpc = "2.0" let id: Int let method = "tools/call" let params: Params } private struct HandsResponse: Decodable { struct RPCError: Decodable { let message: String } let id: Int let result: OpenAPIObjectContainer? let error: RPCError? } private final class Helper: @unchecked Sendable { let process = Process() let input: FileHandle var buffer = Data() var pending: [Int: CheckedContinuation] = [:] init(input: FileHandle) { self.input = input } } private final class Hands: @unchecked Sendable { private let lock = NSLock() private var helper: Helper? private var next = 0 func call(_ tool: String, _ arguments: OpenAPIObjectContainer) async throws -> OpenAPIObjectContainer { try await withCheckedThrowingContinuation { c in lock.withLock { next += 1 let id = next var target: Helper? do { let h = try running() target = h var line = try JSONEncoder().encode(HandsRequest(id: id, params: .init(name: tool, arguments: arguments))) line.append(0x0a) h.pending[id] = c try h.input.write(contentsOf: line) } catch { target?.pending[id] = nil c.resume(throwing: error) } } } } func stop() { lock.withLock { helper?.process.terminate() } } private func running() throws -> Helper { if let helper, helper.process.isRunning { return helper } signal(SIGPIPE, SIG_IGN) let stdin = Pipe() let stdout = Pipe() let h = Helper(input: stdin.fileHandleForWriting) let p = h.process p.executableURL = Bundle.main.executableURL!.deletingLastPathComponent().appending(path: "byre-hands") p.standardInput = stdin p.standardOutput = stdout p.standardError = FileHandle.nullDevice stdout.fileHandleForReading.readabilityHandler = { [weak self] fh in self?.read(h, fh.availableData) } p.terminationHandler = { [weak self] _ in self?.exited(h) } try p.run() log.info("byre-hands started: pid \(p.processIdentifier)") helper = h return h } private func read(_ h: Helper, _ data: Data) { let lines: [Data] = lock.withLock { h.buffer.append(data) var out: [Data] = [] while let nl = h.buffer.firstIndex(of: 0x0a) { out.append(h.buffer[h.buffer.startIndex..