Files
byre/hands/Sources/byre-hands/ChromeBroker.swift
T

238 lines
10 KiB
Swift
Raw Normal View History

import AppKit
import Darwin
// Chrome's pipe transport belongs to one process. All hands processes on this
// account share that owner through a socket in the account's private temp dir.
private let chromeSocketDir = NSTemporaryDirectory() + "byre-chrome"
private let chromeSocketPath = chromeSocketDir + "/control"
private func chromeAddress() throws -> sockaddr_un {
var address = sockaddr_un()
address.sun_family = sa_family_t(AF_UNIX)
let bytes = Array(chromeSocketPath.utf8) + [0]
guard bytes.count <= MemoryLayout.size(ofValue: address.sun_path) else {
throw ToolError("Chrome's private socket path is too long")
}
withUnsafeMutableBytes(of: &address.sun_path) { $0.copyBytes(from: bytes) }
address.sun_len = UInt8(MemoryLayout<sockaddr_un>.size)
return address
}
private func connectChromeBroker() throws -> Int32 {
var address = try chromeAddress()
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw ToolError("create Chrome socket: errno \(errno)") }
let result = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard result == 0 else {
close(fd)
throw ToolError("Chrome's private helper is not running")
}
var noSignal: Int32 = 1
setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
return fd
}
// Both the broker and Chrome are launched in their own process groups, so ending
// one agent's hands process cannot take down the shared browser.
func spawnChromeProcess(_ executable: String, _ args: [String], pipes: (Int32, Int32)? = nil) throws -> pid_t {
var actions: posix_spawn_file_actions_t?
var attrs: posix_spawnattr_t?
posix_spawn_file_actions_init(&actions)
posix_spawnattr_init(&attrs)
defer {
posix_spawn_file_actions_destroy(&actions)
posix_spawnattr_destroy(&attrs)
}
posix_spawnattr_setflags(&attrs, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT))
posix_spawnattr_setpgroup(&attrs, 0)
for fd: Int32 in [0, 1, 2] {
posix_spawn_file_actions_addopen(&actions, fd, "/dev/null", O_RDWR, 0)
}
if let (input, output) = pipes {
posix_spawn_file_actions_adddup2(&actions, input, 3)
posix_spawn_file_actions_adddup2(&actions, output, 4)
}
let argv = ([executable] + args).map { strdup($0) } + [nil]
defer { for p in argv { free(p) } }
var pid: pid_t = 0
let result = argv.withUnsafeBufferPointer {
posix_spawn(&pid, executable, &actions, &attrs, $0.baseAddress!, environ)
}
guard result == 0 else { throw ToolError("launch \(executable): errno \(result)") }
return pid
}
// Foundation's read(upToCount:) can wait to fill its buffer on a socket or
// pipe. A single POSIX read returns the available bytes without waiting for EOF.
func readChromeChunk(_ handle: FileHandle) throws -> Data? {
var bytes = [UInt8](repeating: 0, count: 64 << 10)
while true {
let count = Darwin.read(handle.fileDescriptor, &bytes, bytes.count)
if count > 0 { return Data(bytes.prefix(count)) }
if count == 0 { return nil }
if errno != EINTR { throw ToolError("read Chrome connection: errno \(errno)") }
}
}
private func chromeFrame(_ handle: FileHandle) throws -> Data {
var data = Data()
// One request per connection. Chunked reads avoid per-byte system calls.
while true {
guard let chunk = try readChromeChunk(handle), !chunk.isEmpty else {
throw ToolError("Chrome's private connection closed")
}
if let end = chunk.firstIndex(of: 0) {
data.append(chunk[..<end])
guard data.count <= 256 << 20 else { throw ToolError("Chrome's reply is too large") }
return data
}
data.append(chunk)
guard data.count <= 256 << 20 else { throw ToolError("Chrome's reply is too large") }
}
}
private let chromeClientID = UUID().uuidString
private actor ChromeContexts {
var clients: [String: ChromeContext] = [:]
func get(_ id: String, pid: pid_t) async -> ChromeContext {
clients = clients.filter { _, context in
kill(context.pid, 0) == 0 || errno != ESRCH
}
let context = clients[id] ?? ChromeContext(pid: pid)
clients[id] = context
// A departed helper's target sessions should stop collecting events.
if let connection = await chromeLink.conn, let (chrome, _) = try? await connection.value {
let detached = chrome.lock.withLock {
let ids = chrome.sessions.filter { $0.value.context == nil }.map { $0.key }
for id in ids { chrome.sessions.removeValue(forKey: id) }
return ids
}
for id in detached {
_ = try? await chrome.send("Target.detachFromTarget", ["sessionId": id])
}
}
return context
}
}
private let chromeContexts = ChromeContexts()
let chromeBrokerCalls = DispatchGroup()
func sharedChromeTool(_ args: [String: Any]) async throws -> [Content] {
try await Task.detached {
var fd = try? connectChromeBroker()
if fd == nil {
let pid = try spawnChromeProcess(Bundle.main.executablePath!, ["chrome-broker"])
Thread.detachNewThread {
var status: Int32 = 0
while waitpid(pid, &status, 0) < 0, errno == EINTR {}
}
let start = Date()
while fd == nil, Date().timeIntervalSince(start) < 15 {
await pause(100)
fd = try? connectChromeBroker()
}
}
guard let fd else { throw ToolError("Chrome's private helper did not start within 15s") }
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
defer { try? handle.close() }
var request: [String: Any] = ["arguments": args, "client": chromeClientID]
request["media_dir"] = mediaDir
var data = try JSONSerialization.data(withJSONObject: request)
data.append(0)
try handle.write(contentsOf: data)
let response = try JSONSerialization.jsonObject(with: chromeFrame(handle)) as? [String: Any] ?? [:]
if let error = response.string("error") { throw ToolError(error) }
return (response["content"] as? [[String: Any]] ?? []).compactMap { item in
if item.string("type") == "image", let data = Data(base64Encoded: item.string("data") ?? "") {
return Content.png(data)
}
return Content.text(item.string("text") ?? "")
}
}.value
}
func serveChromeBroker() throws {
let fm = FileManager.default
if !fm.fileExists(atPath: chromeSocketDir) {
do {
try fm.createDirectory(atPath: chromeSocketDir, withIntermediateDirectories: false, attributes: [.posixPermissions: 0o700])
} catch {
if !fm.fileExists(atPath: chromeSocketDir) { throw error }
}
}
let attributes = try fm.attributesOfItem(atPath: chromeSocketDir)
guard attributes[.type] as? FileAttributeType == .typeDirectory,
(attributes[.ownerAccountID] as? NSNumber)?.uint32Value == getuid() else {
throw ToolError("Chrome's socket directory is not owned by this account")
}
try fm.setAttributes([.posixPermissions: 0o700], ofItemAtPath: chromeSocketDir)
let lock = Darwin.open(chromeSocketDir + "/lock", O_CREAT | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0o600)
guard lock >= 0 else { throw ToolError("open Chrome helper lock: errno \(errno)") }
defer { close(lock) }
guard flock(lock, LOCK_EX | LOCK_NB) == 0 else { return }
unlink(chromeSocketPath)
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else { throw ToolError("create Chrome listener: errno \(errno)") }
defer {
close(fd)
unlink(chromeSocketPath)
}
var address = try chromeAddress()
let bound = withUnsafePointer(to: &address) {
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
bind(fd, $0, socklen_t(MemoryLayout<sockaddr_un>.size))
}
}
guard bound == 0, listen(fd, 16) == 0 else { throw ToolError("listen on Chrome socket: errno \(errno)") }
while true {
let client = accept(fd, nil, nil)
if client < 0 {
if errno == EINTR { continue }
throw ToolError("accept Chrome connection: errno \(errno)")
}
// Check the peer too: filesystem permissions alone are not the boundary.
var uid: uid_t = 0
var gid: gid_t = 0
guard getpeereid(client, &uid, &gid) == 0, uid == getuid() else {
close(client)
continue
}
var pid: pid_t = 0
var pidSize = socklen_t(MemoryLayout<pid_t>.size)
guard getsockopt(client, SOL_LOCAL, LOCAL_PEERPID, &pid, &pidSize) == 0 else {
close(client)
continue
}
var noSignal: Int32 = 1
setsockopt(client, SOL_SOCKET, SO_NOSIGPIPE, &noSignal, socklen_t(MemoryLayout<Int32>.size))
chromeBrokerCalls.enter()
Task.detached {
defer { chromeBrokerCalls.leave() }
let handle = FileHandle(fileDescriptor: client, closeOnDealloc: true)
defer { try? handle.close() }
var response: [String: Any]
do {
let request = try JSONSerialization.jsonObject(with: chromeFrame(handle)) as? [String: Any] ?? [:]
let context = await chromeContexts.get(request.string("client") ?? "", pid: pid)
let content = try await chromeTool(request.dict("arguments") ?? [:], saveDir: request.string("media_dir"), context: context)
response = ["content": content.map { $0.json }]
} catch let error as ToolError {
response = ["error": error.message]
} catch {
response = ["error": "\(error)"]
}
if var data = try? JSONSerialization.data(withJSONObject: response) {
data.append(0)
try? handle.write(contentsOf: data)
}
}
}
}