Let task agents use client Macs directly: bash, copy and hands
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
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 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"))
|
||||
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 .copy:
|
||||
try await copy(transfer: req.transfer ?? "", to: req.dest ?? "")
|
||||
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 copy(transfer: String, to dest: String) async throws {
|
||||
let target = URL(filePath: (dest as NSString).expandingTildeInPath)
|
||||
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-copy-\(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 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<OpenAPIObjectContainer, any Error>] = [:]
|
||||
|
||||
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..<nl])
|
||||
h.buffer = Data(h.buffer[(nl + 1)...])
|
||||
}
|
||||
return out
|
||||
}
|
||||
for line in lines {
|
||||
guard let resp = try? JSONDecoder().decode(HandsResponse.self, from: line) else {
|
||||
log.error("byre-hands sent an unreadable line")
|
||||
continue
|
||||
}
|
||||
let c = lock.withLock {
|
||||
h.pending.removeValue(forKey: resp.id)
|
||||
}
|
||||
if let result = resp.result {
|
||||
c?.resume(returning: result)
|
||||
} else {
|
||||
c?.resume(throwing: ApiError(status: 0, message: "byre-hands: \(resp.error?.message ?? "no result")"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func exited(_ h: Helper) {
|
||||
let waiting = lock.withLock {
|
||||
let all = h.pending
|
||||
h.pending = [:]
|
||||
if helper === h {
|
||||
helper = nil
|
||||
}
|
||||
return all
|
||||
}
|
||||
let status = h.process.terminationStatus
|
||||
log.info("byre-hands exited: status \(status)")
|
||||
for c in waiting.values {
|
||||
c.resume(throwing: ApiError(status: 0, message: "byre-hands exited with status \(status) before replying"))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user